lwe.service.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. import { execFile, spawn, type ChildProcess } from 'child_process'
  2. import { promisify } from 'util'
  3. import * as fs from 'fs'
  4. import * as path from 'path'
  5. import * as os from 'os'
  6. import type { BrowserWindow } from 'electron'
  7. import { IpcChannels } from '@shared/ipc-channels'
  8. import type { LweStatus, LweInstallProgress, LinuxDistro } from '@shared/types'
  9. import {
  10. isCommandAvailable,
  11. invalidateCommandCache,
  12. whichCommand,
  13. getConnectedScreens,
  14. getWaylandDisplay,
  15. getXdgRuntimeDir
  16. } from '../utils/platform'
  17. import { getWorkshopPath, getLweManifestPath } from '../utils/paths'
  18. import { getLweRepoUrl, getLweRepoBranch, getLweCmakeArgs } from './config.service'
  19. import { DEFAULT_LWE_REPO } from '@shared/constants'
  20. const execFileAsync = promisify(execFile)
  21. const LWE_BINARY = 'linux-wallpaperengine'
  22. const BUILD_DIR = path.join(os.tmpdir(), 'lwe-build')
  23. /** Common install locations checked before falling back to $PATH. */
  24. const LWE_SEARCH_PATHS = [
  25. // local fork builds take precedence over system installs
  26. path.join(os.homedir(), 'projects', 'private', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
  27. path.join(os.homedir(), 'projects', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
  28. '/usr/local/bin/linux-wallpaperengine',
  29. '/usr/local/linux-wallpaperengine',
  30. '/usr/bin/linux-wallpaperengine',
  31. path.join(os.homedir(), '.local', 'bin', 'linux-wallpaperengine'),
  32. path.join(os.homedir(), 'bin', 'linux-wallpaperengine'),
  33. '/opt/linux-wallpaperengine/linux-wallpaperengine'
  34. ]
  35. let activeProcess: ChildProcess | null = null
  36. async function removeBuildDir(dir: string): Promise<void> {
  37. if (!fs.existsSync(dir)) return
  38. try {
  39. fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 60 })
  40. return
  41. } catch (err) {
  42. const code = (err as NodeJS.ErrnoException).code
  43. if (code !== 'EACCES' && code !== 'ENOTEMPTY' && code !== 'EPERM') throw err
  44. }
  45. const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  46. await execFileAsync(elevate, ['rm', '-rf', dir], { timeout: 30_000, encoding: 'utf8' })
  47. }
  48. interface LweInstallManifest {
  49. files: string[]
  50. symlink: string
  51. }
  52. function readLweManifest(): LweInstallManifest | undefined {
  53. try {
  54. const parsed = JSON.parse(fs.readFileSync(getLweManifestPath(), 'utf8')) as LweInstallManifest
  55. if (Array.isArray(parsed.files) && typeof parsed.symlink === 'string') return parsed
  56. } catch { /* falls back to heuristic cleanup in uninstallLwe */ }
  57. return undefined
  58. }
  59. function writeLweManifest(manifest: LweInstallManifest): void {
  60. try {
  61. fs.writeFileSync(getLweManifestPath(), JSON.stringify(manifest, null, 2), 'utf8')
  62. } catch { /* non-fatal */ }
  63. }
  64. function parseExtraCmakeArgs(raw: string | null): string[] {
  65. if (!raw) return []
  66. const args: string[] = []
  67. let current = ''
  68. let quote: string | null = null
  69. let inToken = false
  70. for (const ch of raw) {
  71. if (quote) {
  72. if (ch === quote) quote = null
  73. else current += ch
  74. continue
  75. }
  76. if (ch === '"' || ch === "'") {
  77. quote = ch
  78. inToken = true
  79. continue
  80. }
  81. if (/\s/.test(ch)) {
  82. if (inToken) { args.push(current); current = ''; inToken = false }
  83. continue
  84. }
  85. current += ch
  86. inToken = true
  87. }
  88. if (inToken) args.push(current)
  89. return args
  90. }
  91. function findLweBinary(): string | undefined {
  92. for (const p of LWE_SEARCH_PATHS) {
  93. try {
  94. fs.accessSync(p, fs.constants.X_OK)
  95. return p
  96. } catch { /* keep looking */ }
  97. }
  98. return whichCommand(LWE_BINARY)
  99. }
  100. export function getLweStatus(): LweStatus {
  101. const found = findLweBinary()
  102. return found ? { installed: true, path: found } : { installed: false }
  103. }
  104. export function detectDistro(): LinuxDistro {
  105. try {
  106. const release = fs.readFileSync('/etc/os-release', 'utf8').toLowerCase()
  107. if (release.includes('id=fedora') || release.includes('id=nobara') || release.includes('id=rhel') || release.includes('id=centos')) return 'fedora'
  108. if (release.includes('id=arch') || release.includes('id=manjaro') || release.includes('id=endeavouros')) return 'arch'
  109. if (release.includes('id=debian') || release.includes('id=ubuntu') || release.includes('id=linuxmint') || release.includes('id=pop')) return 'debian'
  110. } catch { /* no os-release */ }
  111. return 'unknown'
  112. }
  113. // Aligned with https://github.com/Almamu/linux-wallpaperengine (README + Wayland + FBOProvider gmpxx)
  114. const DEPS_DEBIAN = [
  115. 'build-essential', 'cmake', 'pkg-config',
  116. 'libxrandr-dev', 'libxinerama-dev', 'libxcursor-dev', 'libxi-dev',
  117. 'libgl-dev', 'libglew-dev', 'freeglut3-dev', 'libsdl2-dev',
  118. 'liblz4-dev', 'libavcodec-dev', 'libavformat-dev', 'libavutil-dev', 'libswscale-dev',
  119. 'libxxf86vm-dev', 'libglm-dev', 'libglfw3-dev',
  120. 'libmpv-dev', 'mpv', 'libpulse-dev', 'libpulse0', 'libfftw3-dev',
  121. 'libwayland-dev', 'wayland-protocols', 'libegl1-mesa-dev',
  122. 'libgmp-dev', 'patchelf'
  123. ]
  124. // Fedora/Nobara: 'ffmpeg' conflicts with ffmpeg-free, so only ffmpeg-free-devel is listed
  125. const DEPS_FEDORA = [
  126. 'gcc', 'g++', 'cmake', 'pkg-config',
  127. 'libXrandr-devel', 'libXinerama-devel', 'libXcursor-devel', 'libXi-devel',
  128. 'mesa-libGL-devel', 'glew-devel', 'freeglut-devel', 'SDL2-devel',
  129. 'lz4-devel', 'ffmpeg-free-devel',
  130. 'libXxf86vm-devel', 'glm-devel', 'glfw-devel',
  131. 'mpv-devel', 'pulseaudio-libs-devel', 'fftw-devel',
  132. 'wayland-devel', 'wayland-protocols-devel', 'mesa-libEGL-devel',
  133. 'gmp-devel', 'patchelf'
  134. ]
  135. const DEPS_ARCH = [
  136. 'base-devel', 'cmake', 'pkg-config',
  137. 'glew', 'freeglut', 'sdl2', 'lz4', 'ffmpeg',
  138. 'glm', 'glfw', 'mpv', 'libpulse', 'fftw',
  139. 'libxrandr', 'libxinerama', 'libxcursor', 'libxi', 'libxxf86vm',
  140. 'wayland', 'wayland-protocols', 'mesa',
  141. 'gmp', 'patchelf'
  142. ]
  143. export async function installLweDeps(win: BrowserWindow): Promise<void> {
  144. const send = (progress: LweInstallProgress) => {
  145. win.webContents.send(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, progress)
  146. }
  147. const distro = detectDistro()
  148. if (distro === 'unknown') {
  149. send({
  150. stage: 'error',
  151. message: 'Could not detect your Linux distribution. Please install build dependencies manually.',
  152. percentage: 0
  153. })
  154. return
  155. }
  156. let pkgManager: string
  157. let installArgs: string[]
  158. let packages: string[]
  159. switch (distro) {
  160. case 'debian':
  161. pkgManager = 'apt-get'
  162. installArgs = ['install', '-y']
  163. packages = DEPS_DEBIAN
  164. break
  165. case 'fedora':
  166. pkgManager = 'dnf'
  167. // --skip-unavailable: avoid failing on missing/broken repos (e.g. Cursor 403) or package renames
  168. installArgs = ['install', '-y', '--skip-unavailable']
  169. packages = DEPS_FEDORA
  170. break
  171. case 'arch':
  172. pkgManager = 'pacman'
  173. installArgs = ['-S', '--noconfirm', '--needed']
  174. packages = DEPS_ARCH
  175. break
  176. }
  177. const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  178. send({
  179. stage: 'installing-deps',
  180. message: `Installing ${packages.length} packages via ${pkgManager} (sudo required)...`,
  181. percentage: 10
  182. })
  183. try {
  184. await execFileAsync(elevate, [pkgManager, ...installArgs, ...packages], {
  185. timeout: 600_000
  186. })
  187. send({
  188. stage: 'done',
  189. message: 'Build dependencies installed.',
  190. percentage: 100
  191. })
  192. } catch (err) {
  193. send({
  194. stage: 'error',
  195. message: `Failed to install dependencies: ${(err as Error).message}`,
  196. percentage: 0
  197. })
  198. }
  199. }
  200. export async function installLwe(win: BrowserWindow): Promise<void> {
  201. const send = (progress: LweInstallProgress) => {
  202. win.webContents.send(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, progress)
  203. }
  204. // read by the catch handler to decide whether to preserve or wipe the build cache
  205. let isLocalDir = false
  206. try {
  207. send({ stage: 'cloning', message: 'Checking build dependencies...', percentage: 0 })
  208. const missingDeps: string[] = []
  209. for (const dep of ['git', 'cmake', 'make', 'pkg-config']) {
  210. if (!isCommandAvailable(dep)) missingDeps.push(dep)
  211. }
  212. if (missingDeps.length > 0) {
  213. send({
  214. stage: 'error',
  215. message: `Missing build dependencies: ${missingDeps.join(', ')}. Please install them first.`,
  216. percentage: 0
  217. })
  218. return
  219. }
  220. let repo = getLweRepoUrl() ?? DEFAULT_LWE_REPO
  221. if (repo.startsWith('~/')) repo = path.join(os.homedir(), repo.slice(2))
  222. const branch = getLweRepoBranch()
  223. isLocalDir = (() => {
  224. try { return fs.statSync(repo).isDirectory() } catch { return false }
  225. })()
  226. // Reuse the previous build dir when it's the same local source, so CEF's ~2GB download
  227. // and compiled objects survive between iterations instead of a full rebuild every time.
  228. const sourceMarker = path.join(BUILD_DIR, '.lwe-source-path')
  229. const canReuseBuildCache = isLocalDir && (() => {
  230. try { return fs.readFileSync(sourceMarker, 'utf8').trim() === repo } catch { return false }
  231. })()
  232. if (!canReuseBuildCache) {
  233. await removeBuildDir(BUILD_DIR)
  234. }
  235. if (isLocalDir) {
  236. // Copy the working tree instead of `git clone`, so uncommitted edits don't need a commit first.
  237. send({ stage: 'cloning', message: `Copying local source from ${repo}...`, percentage: 5 })
  238. fs.mkdirSync(BUILD_DIR, { recursive: true })
  239. if (isCommandAvailable('rsync')) {
  240. // --delete so files removed from the source don't linger in a reused build dir
  241. await execFileAsync('rsync', [
  242. '-a', '--delete', '--exclude=/.git', '--exclude=/build',
  243. `${repo}/`, `${BUILD_DIR}/`
  244. ], { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 })
  245. } else {
  246. await execFileAsync('bash', [
  247. '-c',
  248. `tar -C ${JSON.stringify(repo)} --exclude=.git --exclude=build -cf - . | tar -C ${JSON.stringify(BUILD_DIR)} -xf -`
  249. ], { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 })
  250. }
  251. fs.writeFileSync(sourceMarker, repo, 'utf8')
  252. } else {
  253. const cloneArgs = ['clone', '--depth', '1', '--recursive']
  254. if (branch) cloneArgs.push('--branch', branch)
  255. cloneArgs.push(repo, BUILD_DIR)
  256. send({
  257. stage: 'cloning',
  258. message: `Cloning ${repo}${branch ? ` (${branch})` : ''}...`,
  259. percentage: 5
  260. })
  261. await execFileAsync('git', cloneArgs, {
  262. timeout: 120_000,
  263. maxBuffer: 10 * 1024 * 1024
  264. })
  265. }
  266. const cmakeBuild = path.join(BUILD_DIR, 'build')
  267. fs.mkdirSync(cmakeBuild, { recursive: true })
  268. // cmake configure downloads CEF on first run, which can take several minutes
  269. send({ stage: 'building', message: 'Running cmake (downloading CEF if needed, may take a few minutes)...', percentage: 25 })
  270. await execFileAsync('cmake', [
  271. '..',
  272. '-DCMAKE_BUILD_TYPE=Release',
  273. '-DCMAKE_INSTALL_PREFIX=/usr/local',
  274. '-DCMAKE_C_FLAGS=-Wno-error',
  275. '-DCMAKE_CXX_FLAGS=-Wno-error',
  276. ...parseExtraCmakeArgs(getLweCmakeArgs())
  277. ], {
  278. cwd: cmakeBuild,
  279. timeout: 600_000,
  280. maxBuffer: 10 * 1024 * 1024,
  281. encoding: 'utf8'
  282. })
  283. // Build
  284. send({ stage: 'building', message: 'Compiling (this may take a few minutes)...', percentage: 40 })
  285. const cores = Math.max(1, os.cpus().length - 1)
  286. await execFileAsync('make', ['-j', String(cores)], {
  287. cwd: cmakeBuild,
  288. timeout: 600_000,
  289. maxBuffer: 10 * 1024 * 1024,
  290. encoding: 'utf8'
  291. })
  292. send({ stage: 'building', message: 'Build complete.', percentage: 80 })
  293. // make install puts the binary at /usr/local/linux-wallpaperengine
  294. send({ stage: 'installing', message: 'Installing (sudo required)...', percentage: 85 })
  295. const installTool = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  296. // pkexec often runs the command in a different cwd (e.g. / or root's home), so run install
  297. // inside a shell that cd's to the build dir first; PATH preserved for make
  298. const pathEnv = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin'
  299. // install_manifest.txt lists every file `make install` placed; capture it so uninstall
  300. // can remove exactly those files instead of guessing.
  301. const manifestMarker = '###LWE_INSTALL_MANIFEST###'
  302. // Local repo: hand the build dir back to the invoking user instead of deleting it, so the
  303. // next unprivileged cmake/make can still write into what `make install` (root) just touched.
  304. const reclaim = isLocalDir
  305. ? `chown -R ${process.getuid!()}:${process.getgid!()} ${JSON.stringify(BUILD_DIR)}`
  306. : `rm -rf ${JSON.stringify(BUILD_DIR)}`
  307. 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; echo ${manifestMarker}; cat install_manifest.txt 2>/dev/null; ${reclaim}`
  308. const { stdout: installOutput } = await execFileAsync(installTool, ['bash', '-c', installScript], {
  309. timeout: 120_000,
  310. maxBuffer: 5 * 1024 * 1024,
  311. encoding: 'utf8'
  312. })
  313. // Symlink binary into PATH so `linux-wallpaperengine` is found
  314. const binaryPath = '/usr/local/linux-wallpaperengine'
  315. const linkPath = '/usr/local/bin/linux-wallpaperengine'
  316. try {
  317. await execFileAsync(installTool, ['ln', '-sf', binaryPath, linkPath], {
  318. timeout: 10_000,
  319. encoding: 'utf8',
  320. maxBuffer: 65536
  321. })
  322. } catch {
  323. // Non-fatal: user may already have it in PATH or can add /usr/local to PATH
  324. }
  325. const manifestIdx = installOutput.indexOf(manifestMarker)
  326. if (manifestIdx !== -1) {
  327. const installedFiles = installOutput.slice(manifestIdx + manifestMarker.length)
  328. .split('\n').map(l => l.trim()).filter(Boolean)
  329. if (installedFiles.length > 0) {
  330. writeLweManifest({ files: installedFiles, symlink: linkPath })
  331. }
  332. }
  333. // Invalidate cache so next status check re-detects
  334. invalidateCommandCache(LWE_BINARY)
  335. const status = getLweStatus()
  336. if (status.installed) {
  337. send({ stage: 'done', message: 'linux-wallpaperengine installed.', percentage: 100 })
  338. } else {
  339. send({
  340. stage: 'error',
  341. message: 'Build completed but binary not found in PATH. You may need to add /usr/local/bin to your PATH.',
  342. percentage: 100
  343. })
  344. }
  345. } catch (err) {
  346. // Failures here happen before the elevated install step, so the build dir isn't root-tainted -
  347. // leave it for a local repo so a fix-and-retry stays incremental.
  348. if (!isLocalDir) {
  349. try {
  350. await removeBuildDir(BUILD_DIR)
  351. } catch { /* ignore cleanup errors */ }
  352. }
  353. // execFile puts stdout/stderr on the error object when encoding/maxBuffer are set
  354. const e = err as Error & { stdout?: string; stderr?: string }
  355. const fullMsg = e.message ?? String(err)
  356. const combined = [e.stdout, e.stderr].filter(Boolean).join('\n')
  357. const lines = (combined || fullMsg).split('\n')
  358. const compilerErrors = lines.filter(
  359. (l: string) => /:\d+:\d+: (?:fatal )?error:/i.test(l) || /^\/.*error:/i.test(l) ||
  360. /undefined reference/i.test(l) || /ld returned/i.test(l) ||
  361. /collect2: error/i.test(l)
  362. )
  363. const otherErrors = lines.filter(
  364. (l: string) => /CMake Error/i.test(l) || /No such file or directory/i.test(l) ||
  365. /Permission denied/i.test(l) || /cannot create/i.test(l) ||
  366. /fatal:/i.test(l) || /failed to/i.test(l)
  367. )
  368. // Prefer compiler/linker errors, then cmake/install errors, then the tail of the log
  369. let excerpt: string
  370. if (compilerErrors.length > 0) {
  371. excerpt = compilerErrors.slice(0, 10).join('\n')
  372. } else if (otherErrors.length > 0) {
  373. excerpt = otherErrors.slice(0, 8).join('\n')
  374. } else {
  375. excerpt = lines.filter((l: string) => l.trim() && !/^make\[\d+\]: (Entering|Leaving)/i.test(l))
  376. .slice(-30).join('\n')
  377. }
  378. send({
  379. stage: 'error',
  380. message: `Build failed:\n${excerpt.trim() || fullMsg}`,
  381. percentage: 0
  382. })
  383. }
  384. }
  385. export async function uninstallLwe(): Promise<{ ok: boolean; message: string }> {
  386. const status = getLweStatus()
  387. if (!status.installed || !status.path) {
  388. return { ok: false, message: 'linux-wallpaperengine is not installed.' }
  389. }
  390. // Stop any running instance first
  391. stopLwe()
  392. const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  393. const manifest = readLweManifest()
  394. let filesToRemove: string[]
  395. let dirsToPrune: string[] = []
  396. if (manifest) {
  397. filesToRemove = [...manifest.files, manifest.symlink]
  398. // deepest first, so a dir only prunes once its own contents are gone
  399. dirsToPrune = [...new Set(manifest.files.map(f => path.dirname(f)))].sort((a, b) => b.length - a.length)
  400. } else {
  401. // no manifest (pre-dates this, or lost): binary + lib dir only, won't clean up CEF resources
  402. const binaryPath = status.path
  403. let realPath: string
  404. try {
  405. realPath = fs.realpathSync(binaryPath)
  406. } catch {
  407. realPath = binaryPath
  408. }
  409. filesToRemove = [realPath]
  410. if (realPath !== binaryPath) filesToRemove.push(binaryPath)
  411. const binDir = path.dirname(realPath)
  412. const libDir = path.join(binDir, 'lib')
  413. if (fs.existsSync(libDir)) {
  414. try {
  415. const entries = fs.readdirSync(libDir)
  416. if (entries.some(e => e.includes('cef') || e.includes('wallpaper'))) {
  417. filesToRemove.push(libDir)
  418. }
  419. } catch { /* ignore */ }
  420. }
  421. }
  422. try {
  423. const quoted = (paths: string[]) => paths.map(p => JSON.stringify(p)).join(' ')
  424. const script = dirsToPrune.length > 0
  425. ? `rm -f ${quoted(filesToRemove)}; rmdir --ignore-fail-on-non-empty -p ${quoted(dirsToPrune)} 2>/dev/null; ldconfig`
  426. : `rm -f ${quoted(filesToRemove)}; ldconfig`
  427. await execFileAsync(elevate, ['bash', '-c', script], {
  428. timeout: 60_000,
  429. encoding: 'utf8'
  430. })
  431. try { fs.rmSync(getLweManifestPath(), { force: true }) } catch { /* ignore */ }
  432. // Invalidate caches
  433. invalidateCommandCache(LWE_BINARY)
  434. return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
  435. } catch (err) {
  436. return { ok: false, message: `Uninstall failed: ${(err as Error).message}` }
  437. }
  438. }
  439. /** Find the Wallpaper Engine assets directory from the Steam install. */
  440. function findWeAssetsDir(): string | undefined {
  441. const workshopPath = getWorkshopPath()
  442. // Workshop path looks like: .../steamapps/workshop/content/431960
  443. // Assets are at: .../steamapps/common/wallpaper_engine/assets
  444. const steamappsIdx = workshopPath.lastIndexOf(path.join('steamapps', 'workshop'))
  445. if (steamappsIdx !== -1) {
  446. const steamappsRoot = workshopPath.substring(0, steamappsIdx + 'steamapps'.length)
  447. const assetsDir = path.join(steamappsRoot, 'common', 'wallpaper_engine', 'assets')
  448. if (fs.existsSync(assetsDir)) return assetsDir
  449. }
  450. // Fallback: scan common Steam locations
  451. const home = os.homedir()
  452. const candidates = [
  453. path.join(home, '.steam', 'steam', 'steamapps', 'common', 'wallpaper_engine', 'assets'),
  454. path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'wallpaper_engine', 'assets')
  455. ]
  456. return candidates.find(p => fs.existsSync(p))
  457. }
  458. /** Directory containing the LWE binary; used for LD_LIBRARY_PATH. */
  459. function getLweLibDir(): string {
  460. const p = findLweBinary()
  461. if (!p) return '/usr/local'
  462. try {
  463. const resolved = fs.realpathSync(p)
  464. return path.dirname(resolved)
  465. } catch {
  466. return path.dirname(p)
  467. }
  468. }
  469. /** LD_LIBRARY_PATH value so the loader finds LWE's .so files. */
  470. function getLweLdLibraryPath(): string {
  471. const binDir = getLweLibDir()
  472. const candidates = [
  473. // For local/fork builds, the .so files live next to the binary
  474. binDir,
  475. path.join(binDir, 'lib64'),
  476. path.join(binDir, 'lib'),
  477. '/usr/local/lib64',
  478. '/usr/local/lib'
  479. ]
  480. // Only include paths that actually exist
  481. const paths = candidates.filter(p => {
  482. try { return fs.statSync(p).isDirectory() } catch { return false }
  483. })
  484. const existing = process.env.LD_LIBRARY_PATH
  485. if (existing) paths.push(existing)
  486. return paths.join(':')
  487. }
  488. /** Full path to LWE binary (resolved symlink). */
  489. function getLweBinaryPath(): string {
  490. const p = findLweBinary()
  491. if (!p) return LWE_BINARY
  492. try {
  493. return fs.realpathSync(p)
  494. } catch {
  495. return p
  496. }
  497. }
  498. /** Build env vars that must be set for LWE, as KEY=VALUE strings for use with `env` command. */
  499. function buildLweEnvVars(): string[] {
  500. const vars: string[] = []
  501. vars.push(`LD_LIBRARY_PATH=${getLweLdLibraryPath()}`)
  502. // CEF bundles its own libEGL.so alongside the LWE binary. LD_LIBRARY_PATH includes the
  503. // binary's directory so CEF's EGL shadows Mesa's system EGL, breaking Wayland rendering.
  504. // Force the system EGL via LD_PRELOAD so Mesa's implementation is always used.
  505. const systemEgl = ['/lib64/libEGL.so.1', '/usr/lib64/libEGL.so.1', '/usr/lib/libEGL.so.1']
  506. .find(p => fs.existsSync(p))
  507. if (systemEgl) vars.push(`LD_PRELOAD=${systemEgl}`)
  508. const waylandDisplay = getWaylandDisplay()
  509. if (waylandDisplay) vars.push(`WAYLAND_DISPLAY=${waylandDisplay}`)
  510. // XDG_SESSION_TYPE is how LWE decides between X11 and Wayland drivers
  511. const sessionType = process.env.XDG_SESSION_TYPE
  512. if (sessionType) vars.push(`XDG_SESSION_TYPE=${sessionType}`)
  513. // DISPLAY for X11/XWayland
  514. if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
  515. // XDG_RUNTIME_DIR is needed for the Wayland socket
  516. vars.push(`XDG_RUNTIME_DIR=${getXdgRuntimeDir()}`)
  517. return vars
  518. }
  519. /** Build the common LWE args (assets dir, screen roots, fps, volume). */
  520. function buildLweArgs(
  521. wallpaperPath: string,
  522. options: { screenRoot?: string; fps?: number; volume?: number }
  523. ): string[] {
  524. const args: string[] = []
  525. const assetsDir = findWeAssetsDir()
  526. if (assetsDir) args.push('--assets-dir', assetsDir)
  527. // --screen-root uses layer-shell (Wayland) or root-window overlay (X11)
  528. if (options.screenRoot) {
  529. args.push('--screen-root', options.screenRoot)
  530. } else {
  531. const screens = getConnectedScreens()
  532. for (const s of screens) args.push('--screen-root', s)
  533. }
  534. if (options.fps) args.push('--fps', String(options.fps))
  535. if (options.volume !== undefined) args.push('--volume', String(options.volume))
  536. args.push(wallpaperPath)
  537. return args
  538. }
  539. /** Control file used for hot-reload signalling. */
  540. function getControlFilePath(): string {
  541. return path.join(getXdgRuntimeDir(), 'lwe-control')
  542. }
  543. /** Hot-reload: write new wallpaper path to control file and send SIGUSR1. */
  544. function hotReloadLwe(wallpaperPath: string): boolean {
  545. if (!activeProcess || activeProcess.exitCode !== null) return false
  546. try {
  547. fs.writeFileSync(getControlFilePath(), wallpaperPath + '\n')
  548. activeProcess.kill('SIGUSR1')
  549. console.log('[LWE] Hot-reload signal sent for:', wallpaperPath)
  550. return true
  551. } catch (err) {
  552. console.error('[LWE] Hot-reload failed:', err)
  553. return false
  554. }
  555. }
  556. /** Launch LWE and resolve after 2s if still running; reject if it exits with error (so UI can show message). */
  557. export function launchLweAsync(
  558. wallpaperPath: string,
  559. options: { screenRoot?: string; fps?: number; volume?: number } = {}
  560. ): Promise<void> {
  561. // Hot-reload if already running
  562. if (isLweRunning() && hotReloadLwe(wallpaperPath)) {
  563. return Promise.resolve()
  564. }
  565. stopLwe()
  566. const binaryPath = getLweBinaryPath()
  567. const lweArgs = buildLweArgs(wallpaperPath, options)
  568. const envVars = buildLweEnvVars()
  569. // Spawn via `env` to set env vars explicitly, matching how LWE works from the terminal.
  570. // The env option on spawn can lose vars when combined with detached/setsid.
  571. const spawnArgs = [...envVars, binaryPath, ...lweArgs]
  572. console.log('[LWE] Binary:', binaryPath)
  573. console.log('[LWE] Args:', lweArgs.join(' '))
  574. console.log('[LWE] Env:', envVars.join(' '))
  575. return new Promise((resolve, reject) => {
  576. const child = spawn('env', spawnArgs, {
  577. stdio: ['ignore', 'pipe', 'pipe'],
  578. detached: true
  579. })
  580. activeProcess = child
  581. const stderrChunks: Buffer[] = []
  582. if (child.stdout) {
  583. child.stdout.on('data', (chunk: Buffer) => {
  584. const msg = chunk.toString('utf8').trim()
  585. if (msg) console.log('[LWE stdout]', msg)
  586. })
  587. }
  588. if (child.stderr) {
  589. child.stderr.on('data', (chunk: Buffer) => {
  590. stderrChunks.push(chunk)
  591. const msg = chunk.toString('utf8').trim()
  592. if (msg) console.log('[LWE stderr]', msg)
  593. })
  594. }
  595. let settled = false
  596. const cleanup = () => {
  597. activeProcess = null
  598. }
  599. child.on('exit', (code) => {
  600. cleanup()
  601. if (settled) return
  602. settled = true
  603. if (code !== 0 && code !== null) {
  604. const stderr = Buffer.concat(stderrChunks).toString('utf8').trim()
  605. const msg = stderr ? stderr.split('\n').slice(0, 3).join('\n') : `Exit code ${code}`
  606. reject(new Error(`linux-wallpaperengine failed: ${msg}`))
  607. } else {
  608. resolve()
  609. }
  610. })
  611. child.on('error', (err) => {
  612. cleanup()
  613. if (!settled) {
  614. settled = true
  615. reject(err)
  616. }
  617. })
  618. child.unref()
  619. // If still running after 2s, consider it started successfully
  620. setTimeout(() => {
  621. if (!settled && activeProcess === child && child.exitCode === null) {
  622. settled = true
  623. resolve()
  624. }
  625. }, 2000)
  626. })
  627. }
  628. export function stopLwe(): void {
  629. if (activeProcess) {
  630. try {
  631. activeProcess.kill('SIGTERM')
  632. } catch { /* already dead */ }
  633. activeProcess = null
  634. }
  635. }
  636. export function isLweRunning(): boolean {
  637. return activeProcess !== null && activeProcess.exitCode === null
  638. }
  639. /** Force-kill every linux-wallpaperengine process on the system, including ones this app isn't tracking. */
  640. export async function killAllLweProcesses(): Promise<{ ok: boolean; message: string }> {
  641. activeProcess = null
  642. try {
  643. const { stdout } = await execFileAsync('pkill', ['-9', '-f', '-c', LWE_BINARY])
  644. const count = parseInt(stdout.trim(), 10) || 0
  645. return { ok: true, message: `Killed ${count} process${count === 1 ? '' : 'es'}.` }
  646. } catch (err) {
  647. const code = (err as { code?: number }).code
  648. // pkill exits 1 when nothing matched, that's not a failure here
  649. if (code === 1) {
  650. return { ok: true, message: 'No running processes found.' }
  651. }
  652. return { ok: false, message: `Failed to kill processes: ${(err as Error).message}` }
  653. }
  654. }