lwe.service.ts 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211
  1. import { execFile, execFileSync, 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 {
  9. LweStatus,
  10. LweInstallProgress,
  11. LinuxDistro,
  12. LweSceneObject,
  13. LweProperty,
  14. LweAudioObject
  15. } from '@shared/types'
  16. import {
  17. isCommandAvailable,
  18. invalidateCommandCache,
  19. whichCommand,
  20. getConnectedScreens,
  21. getWaylandDisplay,
  22. getXdgRuntimeDir
  23. } from '../utils/platform'
  24. import { getWorkshopPath, getLweManifestPath } from '../utils/paths'
  25. import {
  26. getLweRepoUrl,
  27. getLweRepoBranch,
  28. getLweCmakeArgs,
  29. getAudioScreen,
  30. getAmbientVolume,
  31. getDefaultAudioSensitivity
  32. } from './config.service'
  33. import { DEFAULT_LWE_REPO } from '@shared/constants'
  34. const execFileAsync = promisify(execFile)
  35. /**
  36. * Env for spawning the engine's own cmake/make, stripped of AppImage runtime vars. When
  37. * we_manager itself runs as an AppImage, AppRun sets LD_LIBRARY_PATH (and friends) to point into
  38. * its own ephemeral /tmp/.mount_XXXXXXX mount so its bundled Electron libs resolve - inheriting
  39. * that into the engine's build lets CMake's library search pick up we_manager's own bundled
  40. * libEGL.so etc. and cache that dead path in CMakeCache.txt once the mount is gone.
  41. */
  42. function buildCleanBuildEnv(): NodeJS.ProcessEnv {
  43. const env = { ...process.env }
  44. for (const key of ['LD_LIBRARY_PATH', 'LD_PRELOAD', 'APPDIR', 'APPIMAGE', 'OWD', 'ARGV0']) {
  45. delete env[key]
  46. }
  47. return env
  48. }
  49. const LWE_BINARY = 'linux-wallpaperengine'
  50. const BUILD_DIR = path.join(os.tmpdir(), 'lwe-build')
  51. /** Common install locations checked before falling back to $PATH. */
  52. const LWE_SEARCH_PATHS = [
  53. // local fork builds take precedence over system installs
  54. path.join(os.homedir(), 'projects', 'private', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
  55. path.join(os.homedir(), 'projects', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
  56. '/usr/local/bin/linux-wallpaperengine',
  57. '/usr/local/linux-wallpaperengine',
  58. '/usr/bin/linux-wallpaperengine',
  59. path.join(os.homedir(), '.local', 'bin', 'linux-wallpaperengine'),
  60. path.join(os.homedir(), 'bin', 'linux-wallpaperengine'),
  61. '/opt/linux-wallpaperengine/linux-wallpaperengine'
  62. ]
  63. let activeProcess: ChildProcess | null = null
  64. async function removeBuildDir(dir: string): Promise<void> {
  65. if (!fs.existsSync(dir)) return
  66. try {
  67. fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 60 })
  68. return
  69. } catch (err) {
  70. const code = (err as NodeJS.ErrnoException).code
  71. if (code !== 'EACCES' && code !== 'ENOTEMPTY' && code !== 'EPERM') throw err
  72. }
  73. const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  74. await execFileAsync(elevate, ['rm', '-rf', dir], { timeout: 30_000, encoding: 'utf8' })
  75. }
  76. interface LweInstallManifest {
  77. files: string[]
  78. symlink: string
  79. }
  80. function readLweManifest(): LweInstallManifest | undefined {
  81. try {
  82. const parsed = JSON.parse(fs.readFileSync(getLweManifestPath(), 'utf8')) as LweInstallManifest
  83. if (Array.isArray(parsed.files) && typeof parsed.symlink === 'string') return parsed
  84. } catch { /* falls back to heuristic cleanup in uninstallLwe */ }
  85. return undefined
  86. }
  87. function writeLweManifest(manifest: LweInstallManifest): void {
  88. try {
  89. fs.writeFileSync(getLweManifestPath(), JSON.stringify(manifest, null, 2), 'utf8')
  90. } catch { /* non-fatal */ }
  91. }
  92. function parseExtraCmakeArgs(raw: string | null): string[] {
  93. if (!raw) return []
  94. const args: string[] = []
  95. let current = ''
  96. let quote: string | null = null
  97. let inToken = false
  98. for (const ch of raw) {
  99. if (quote) {
  100. if (ch === quote) quote = null
  101. else current += ch
  102. continue
  103. }
  104. if (ch === '"' || ch === "'") {
  105. quote = ch
  106. inToken = true
  107. continue
  108. }
  109. if (/\s/.test(ch)) {
  110. if (inToken) { args.push(current); current = ''; inToken = false }
  111. continue
  112. }
  113. current += ch
  114. inToken = true
  115. }
  116. if (inToken) args.push(current)
  117. return args
  118. }
  119. function findLweBinary(): string | undefined {
  120. for (const p of LWE_SEARCH_PATHS) {
  121. try {
  122. fs.accessSync(p, fs.constants.X_OK)
  123. return p
  124. } catch { /* keep looking */ }
  125. }
  126. return whichCommand(LWE_BINARY)
  127. }
  128. export function getLweStatus(): LweStatus {
  129. const found = findLweBinary()
  130. return found ? { installed: true, path: found } : { installed: false }
  131. }
  132. export function detectDistro(): LinuxDistro {
  133. try {
  134. const release = fs.readFileSync('/etc/os-release', 'utf8').toLowerCase()
  135. if (release.includes('id=fedora') || release.includes('id=nobara') || release.includes('id=rhel') || release.includes('id=centos')) return 'fedora'
  136. if (release.includes('id=arch') || release.includes('id=manjaro') || release.includes('id=endeavouros')) return 'arch'
  137. if (release.includes('id=debian') || release.includes('id=ubuntu') || release.includes('id=linuxmint') || release.includes('id=pop')) return 'debian'
  138. } catch { /* no os-release */ }
  139. return 'unknown'
  140. }
  141. // Aligned with https://github.com/Almamu/linux-wallpaperengine (README + Wayland + FBOProvider gmpxx)
  142. const DEPS_DEBIAN = [
  143. 'build-essential', 'cmake', 'pkg-config',
  144. 'libxrandr-dev', 'libxinerama-dev', 'libxcursor-dev', 'libxi-dev',
  145. 'libgl-dev', 'libglew-dev', 'freeglut3-dev', 'libsdl2-dev',
  146. 'liblz4-dev', 'libavcodec-dev', 'libavformat-dev', 'libavutil-dev', 'libswscale-dev',
  147. 'libxxf86vm-dev', 'libglm-dev', 'libglfw3-dev',
  148. 'libmpv-dev', 'mpv', 'libpulse-dev', 'libpulse0', 'libfftw3-dev',
  149. 'libwayland-dev', 'wayland-protocols', 'libegl1-mesa-dev',
  150. 'libgmp-dev', 'patchelf'
  151. ]
  152. // Fedora/Nobara: 'ffmpeg' conflicts with ffmpeg-free, so only ffmpeg-free-devel is listed
  153. const DEPS_FEDORA = [
  154. 'gcc', 'g++', 'cmake', 'pkg-config',
  155. 'libXrandr-devel', 'libXinerama-devel', 'libXcursor-devel', 'libXi-devel',
  156. 'mesa-libGL-devel', 'glew-devel', 'freeglut-devel', 'SDL2-devel',
  157. 'lz4-devel', 'ffmpeg-free-devel',
  158. 'libXxf86vm-devel', 'glm-devel', 'glfw-devel',
  159. 'mpv-devel', 'pulseaudio-libs-devel', 'fftw-devel',
  160. 'wayland-devel', 'wayland-protocols-devel', 'mesa-libEGL-devel',
  161. 'gmp-devel', 'patchelf'
  162. ]
  163. const DEPS_ARCH = [
  164. 'base-devel', 'cmake', 'pkg-config',
  165. 'glew', 'freeglut', 'sdl2', 'lz4', 'ffmpeg',
  166. 'glm', 'glfw', 'mpv', 'libpulse', 'fftw',
  167. 'libxrandr', 'libxinerama', 'libxcursor', 'libxi', 'libxxf86vm',
  168. 'wayland', 'wayland-protocols', 'mesa',
  169. 'gmp', 'patchelf'
  170. ]
  171. export async function installLweDeps(win: BrowserWindow): Promise<void> {
  172. const send = (progress: LweInstallProgress) => {
  173. win.webContents.send(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, progress)
  174. }
  175. const distro = detectDistro()
  176. if (distro === 'unknown') {
  177. send({
  178. stage: 'error',
  179. message: 'Could not detect your Linux distribution. Please install build dependencies manually.',
  180. percentage: 0
  181. })
  182. return
  183. }
  184. let pkgManager: string
  185. let installArgs: string[]
  186. let packages: string[]
  187. switch (distro) {
  188. case 'debian':
  189. pkgManager = 'apt-get'
  190. installArgs = ['install', '-y']
  191. packages = DEPS_DEBIAN
  192. break
  193. case 'fedora':
  194. pkgManager = 'dnf'
  195. // --skip-unavailable: avoid failing on missing/broken repos (e.g. Cursor 403) or package renames
  196. installArgs = ['install', '-y', '--skip-unavailable']
  197. packages = DEPS_FEDORA
  198. break
  199. case 'arch':
  200. pkgManager = 'pacman'
  201. installArgs = ['-S', '--noconfirm', '--needed']
  202. packages = DEPS_ARCH
  203. break
  204. }
  205. const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  206. send({
  207. stage: 'installing-deps',
  208. message: `Installing ${packages.length} packages via ${pkgManager} (sudo required)...`,
  209. percentage: 10
  210. })
  211. try {
  212. await execFileAsync(elevate, [pkgManager, ...installArgs, ...packages], {
  213. timeout: 600_000
  214. })
  215. send({
  216. stage: 'done',
  217. message: 'Build dependencies installed.',
  218. percentage: 100
  219. })
  220. } catch (err) {
  221. send({
  222. stage: 'error',
  223. message: `Failed to install dependencies: ${(err as Error).message}`,
  224. percentage: 0
  225. })
  226. }
  227. }
  228. export async function installLwe(win: BrowserWindow): Promise<void> {
  229. const send = (progress: LweInstallProgress) => {
  230. win.webContents.send(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, progress)
  231. }
  232. // read by the catch handler to decide whether to preserve or wipe the build cache
  233. let isLocalDir = false
  234. try {
  235. send({ stage: 'cloning', message: 'Checking build dependencies...', percentage: 0 })
  236. const missingDeps: string[] = []
  237. for (const dep of ['git', 'cmake', 'make', 'pkg-config']) {
  238. if (!isCommandAvailable(dep)) missingDeps.push(dep)
  239. }
  240. if (missingDeps.length > 0) {
  241. send({
  242. stage: 'error',
  243. message: `Missing build dependencies: ${missingDeps.join(', ')}. Please install them first.`,
  244. percentage: 0
  245. })
  246. return
  247. }
  248. let repo = getLweRepoUrl() ?? DEFAULT_LWE_REPO
  249. if (repo.startsWith('~/')) repo = path.join(os.homedir(), repo.slice(2))
  250. const branch = getLweRepoBranch()
  251. isLocalDir = (() => {
  252. try { return fs.statSync(repo).isDirectory() } catch { return false }
  253. })()
  254. // Reuse the previous build dir when it's the same local source, so CEF's ~2GB download
  255. // and compiled objects survive between iterations instead of a full rebuild every time.
  256. const sourceMarker = path.join(BUILD_DIR, '.lwe-source-path')
  257. const canReuseBuildCache = isLocalDir && (() => {
  258. try { return fs.readFileSync(sourceMarker, 'utf8').trim() === repo } catch { return false }
  259. })()
  260. if (!canReuseBuildCache) {
  261. await removeBuildDir(BUILD_DIR)
  262. }
  263. if (isLocalDir) {
  264. // Copy the working tree instead of `git clone`, so uncommitted edits don't need a commit first.
  265. send({ stage: 'cloning', message: `Copying local source from ${repo}...`, percentage: 5 })
  266. fs.mkdirSync(BUILD_DIR, { recursive: true })
  267. if (isCommandAvailable('rsync')) {
  268. // --delete so files removed from the source don't linger in a reused build dir.
  269. // --no-times overrides -a's mtime preservation - the build dir is reused incrementally
  270. // (see canReuseBuildCache above), so a stale source mtime can make `make` skip
  271. // recompiling a changed file.
  272. await execFileAsync('rsync', [
  273. '-a', '--no-times', '--delete', '--exclude=/.git', '--exclude=/build',
  274. `${repo}/`, `${BUILD_DIR}/`
  275. ], { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 })
  276. } else {
  277. // -m on extraction, same reason as --no-times above.
  278. await execFileAsync('bash', [
  279. '-c',
  280. `tar -C ${JSON.stringify(repo)} --exclude=.git --exclude=build -cf - . | tar -C ${JSON.stringify(BUILD_DIR)} -xmf -`
  281. ], { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 })
  282. }
  283. fs.writeFileSync(sourceMarker, repo, 'utf8')
  284. } else {
  285. const cloneArgs = ['clone', '--depth', '1', '--recursive']
  286. if (branch) cloneArgs.push('--branch', branch)
  287. cloneArgs.push(repo, BUILD_DIR)
  288. send({
  289. stage: 'cloning',
  290. message: `Cloning ${repo}${branch ? ` (${branch})` : ''}...`,
  291. percentage: 5
  292. })
  293. await execFileAsync('git', cloneArgs, {
  294. timeout: 120_000,
  295. maxBuffer: 10 * 1024 * 1024
  296. })
  297. }
  298. const cmakeBuild = path.join(BUILD_DIR, 'build')
  299. fs.mkdirSync(cmakeBuild, { recursive: true })
  300. const buildEnv = buildCleanBuildEnv()
  301. // cmake configure downloads CEF on first run, which can take several minutes
  302. send({ stage: 'building', message: 'Running cmake (downloading CEF if needed, may take a few minutes)...', percentage: 25 })
  303. await execFileAsync('cmake', [
  304. '..',
  305. '-DCMAKE_BUILD_TYPE=Release',
  306. '-DCMAKE_INSTALL_PREFIX=/usr/local',
  307. '-DCMAKE_C_FLAGS=-Wno-error',
  308. '-DCMAKE_CXX_FLAGS=-Wno-error',
  309. ...parseExtraCmakeArgs(getLweCmakeArgs())
  310. ], {
  311. cwd: cmakeBuild,
  312. env: buildEnv,
  313. timeout: 600_000,
  314. maxBuffer: 10 * 1024 * 1024,
  315. encoding: 'utf8'
  316. })
  317. send({ stage: 'building', message: 'Compiling (this may take a few minutes)...', percentage: 40 })
  318. const cores = Math.max(1, os.cpus().length - 1)
  319. await execFileAsync('make', ['-j', String(cores)], {
  320. cwd: cmakeBuild,
  321. env: buildEnv,
  322. timeout: 600_000,
  323. maxBuffer: 10 * 1024 * 1024,
  324. encoding: 'utf8'
  325. })
  326. send({ stage: 'building', message: 'Build complete.', percentage: 80 })
  327. // make install puts the binary at /usr/local/linux-wallpaperengine
  328. send({ stage: 'installing', message: 'Installing (sudo required)...', percentage: 85 })
  329. const installTool = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  330. // pkexec often runs the command in a different cwd (e.g. / or root's home), so run install
  331. // inside a shell that cd's to the build dir first; PATH preserved for make
  332. const pathEnv = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin'
  333. // install_manifest.txt lists every file `make install` placed; capture it so uninstall
  334. // can remove exactly those files instead of guessing.
  335. const manifestMarker = '###LWE_INSTALL_MANIFEST###'
  336. // Local repo: hand the build dir back to the invoking user instead of deleting it, so the
  337. // next unprivileged cmake/make can still write into what `make install` (root) just touched.
  338. const reclaim = isLocalDir
  339. ? `chown -R ${process.getuid!()}:${process.getgid!()} ${JSON.stringify(BUILD_DIR)}`
  340. : `rm -rf ${JSON.stringify(BUILD_DIR)}`
  341. 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}`
  342. const { stdout: installOutput } = await execFileAsync(installTool, ['bash', '-c', installScript], {
  343. timeout: 120_000,
  344. maxBuffer: 5 * 1024 * 1024,
  345. encoding: 'utf8'
  346. })
  347. // Symlink binary into PATH so `linux-wallpaperengine` is found
  348. const binaryPath = '/usr/local/linux-wallpaperengine'
  349. const linkPath = '/usr/local/bin/linux-wallpaperengine'
  350. try {
  351. await execFileAsync(installTool, ['ln', '-sf', binaryPath, linkPath], {
  352. timeout: 10_000,
  353. encoding: 'utf8',
  354. maxBuffer: 65536
  355. })
  356. } catch {
  357. // Non-fatal: user may already have it in PATH or can add /usr/local to PATH
  358. }
  359. const manifestIdx = installOutput.indexOf(manifestMarker)
  360. if (manifestIdx !== -1) {
  361. const installedFiles = installOutput.slice(manifestIdx + manifestMarker.length)
  362. .split('\n').map(l => l.trim()).filter(Boolean)
  363. if (installedFiles.length > 0) {
  364. writeLweManifest({ files: installedFiles, symlink: linkPath })
  365. }
  366. }
  367. // Invalidate cache so next status check re-detects
  368. invalidateCommandCache(LWE_BINARY)
  369. invalidateObjectFlagsSupport()
  370. invalidateAudioSensitivitySupport()
  371. const status = getLweStatus()
  372. if (status.installed) {
  373. send({ stage: 'done', message: 'linux-wallpaperengine installed.', percentage: 100 })
  374. } else {
  375. send({
  376. stage: 'error',
  377. message: 'Build completed but binary not found in PATH. You may need to add /usr/local/bin to your PATH.',
  378. percentage: 100
  379. })
  380. }
  381. } catch (err) {
  382. // Failures here happen before the elevated install step, so the build dir isn't root-tainted -
  383. // leave it for a local repo so a fix-and-retry stays incremental.
  384. if (!isLocalDir) {
  385. try {
  386. await removeBuildDir(BUILD_DIR)
  387. } catch { /* ignore cleanup errors */ }
  388. }
  389. // execFile puts stdout/stderr on the error object when encoding/maxBuffer are set
  390. const e = err as Error & { stdout?: string; stderr?: string }
  391. const fullMsg = e.message ?? String(err)
  392. const combined = [e.stdout, e.stderr].filter(Boolean).join('\n')
  393. const lines = (combined || fullMsg).split('\n')
  394. const compilerErrors = lines.filter(
  395. (l: string) => /:\d+:\d+: (?:fatal )?error:/i.test(l) || /^\/.*error:/i.test(l) ||
  396. /undefined reference/i.test(l) || /ld returned/i.test(l) ||
  397. /collect2: error/i.test(l)
  398. )
  399. const otherErrors = lines.filter(
  400. (l: string) => /CMake Error/i.test(l) || /No such file or directory/i.test(l) ||
  401. /Permission denied/i.test(l) || /cannot create/i.test(l) ||
  402. /fatal:/i.test(l) || /failed to/i.test(l)
  403. )
  404. // Prefer compiler/linker errors, then cmake/install errors, then the tail of the log
  405. let excerpt: string
  406. if (compilerErrors.length > 0) {
  407. excerpt = compilerErrors.slice(0, 10).join('\n')
  408. } else if (otherErrors.length > 0) {
  409. excerpt = otherErrors.slice(0, 8).join('\n')
  410. } else {
  411. excerpt = lines.filter((l: string) => l.trim() && !/^make\[\d+\]: (Entering|Leaving)/i.test(l))
  412. .slice(-30).join('\n')
  413. }
  414. send({
  415. stage: 'error',
  416. message: `Build failed:\n${excerpt.trim() || fullMsg}`,
  417. percentage: 0
  418. })
  419. }
  420. }
  421. export async function uninstallLwe(): Promise<{ ok: boolean; message: string }> {
  422. const status = getLweStatus()
  423. if (!status.installed || !status.path) {
  424. return { ok: false, message: 'linux-wallpaperengine is not installed.' }
  425. }
  426. stopLwe()
  427. const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
  428. const manifest = readLweManifest()
  429. let filesToRemove: string[]
  430. let dirsToPrune: string[] = []
  431. if (manifest) {
  432. filesToRemove = [...manifest.files, manifest.symlink]
  433. // deepest first, so a dir only prunes once its own contents are gone
  434. dirsToPrune = [...new Set(manifest.files.map(f => path.dirname(f)))].sort((a, b) => b.length - a.length)
  435. } else {
  436. // no manifest (pre-dates this, or lost): binary + lib dir only, won't clean up CEF resources
  437. const binaryPath = status.path
  438. let realPath: string
  439. try {
  440. realPath = fs.realpathSync(binaryPath)
  441. } catch {
  442. realPath = binaryPath
  443. }
  444. filesToRemove = [realPath]
  445. if (realPath !== binaryPath) filesToRemove.push(binaryPath)
  446. const binDir = path.dirname(realPath)
  447. const libDir = path.join(binDir, 'lib')
  448. if (fs.existsSync(libDir)) {
  449. try {
  450. const entries = fs.readdirSync(libDir)
  451. if (entries.some(e => e.includes('cef') || e.includes('wallpaper'))) {
  452. filesToRemove.push(libDir)
  453. }
  454. } catch { /* ignore */ }
  455. }
  456. }
  457. try {
  458. const quoted = (paths: string[]) => paths.map(p => JSON.stringify(p)).join(' ')
  459. const script = dirsToPrune.length > 0
  460. ? `rm -f ${quoted(filesToRemove)}; rmdir --ignore-fail-on-non-empty -p ${quoted(dirsToPrune)} 2>/dev/null; ldconfig`
  461. : `rm -f ${quoted(filesToRemove)}; ldconfig`
  462. await execFileAsync(elevate, ['bash', '-c', script], {
  463. timeout: 60_000,
  464. encoding: 'utf8'
  465. })
  466. try { fs.rmSync(getLweManifestPath(), { force: true }) } catch { /* ignore */ }
  467. invalidateCommandCache(LWE_BINARY)
  468. invalidateObjectFlagsSupport()
  469. invalidateAudioSensitivitySupport()
  470. return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
  471. } catch (err) {
  472. return { ok: false, message: `Uninstall failed: ${(err as Error).message}` }
  473. }
  474. }
  475. /** Find the Wallpaper Engine assets directory from the Steam install. */
  476. function findWeAssetsDir(): string | undefined {
  477. const workshopPath = getWorkshopPath()
  478. // Workshop path looks like: .../steamapps/workshop/content/431960
  479. // Assets are at: .../steamapps/common/wallpaper_engine/assets
  480. const steamappsIdx = workshopPath.lastIndexOf(path.join('steamapps', 'workshop'))
  481. if (steamappsIdx !== -1) {
  482. const steamappsRoot = workshopPath.substring(0, steamappsIdx + 'steamapps'.length)
  483. const assetsDir = path.join(steamappsRoot, 'common', 'wallpaper_engine', 'assets')
  484. if (fs.existsSync(assetsDir)) return assetsDir
  485. }
  486. // Fallback: scan common Steam locations
  487. const home = os.homedir()
  488. const candidates = [
  489. path.join(home, '.steam', 'steam', 'steamapps', 'common', 'wallpaper_engine', 'assets'),
  490. path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'wallpaper_engine', 'assets')
  491. ]
  492. return candidates.find(p => fs.existsSync(p))
  493. }
  494. /** Directory containing the LWE binary; used for LD_LIBRARY_PATH. */
  495. function getLweLibDir(): string {
  496. const p = findLweBinary()
  497. if (!p) return '/usr/local'
  498. try {
  499. const resolved = fs.realpathSync(p)
  500. return path.dirname(resolved)
  501. } catch {
  502. return path.dirname(p)
  503. }
  504. }
  505. /** LD_LIBRARY_PATH value so the loader finds LWE's .so files. */
  506. function getLweLdLibraryPath(): string {
  507. const binDir = getLweLibDir()
  508. const candidates = [
  509. // For local/fork builds, the .so files live next to the binary
  510. binDir,
  511. path.join(binDir, 'lib64'),
  512. path.join(binDir, 'lib'),
  513. '/usr/local/lib64',
  514. '/usr/local/lib'
  515. ]
  516. const paths = candidates.filter(p => {
  517. try { return fs.statSync(p).isDirectory() } catch { return false }
  518. })
  519. const existing = process.env.LD_LIBRARY_PATH
  520. if (existing) paths.push(existing)
  521. return paths.join(':')
  522. }
  523. /** Full path to LWE binary (resolved symlink). */
  524. function getLweBinaryPath(): string {
  525. const p = findLweBinary()
  526. if (!p) return LWE_BINARY
  527. try {
  528. return fs.realpathSync(p)
  529. } catch {
  530. return p
  531. }
  532. }
  533. /** Build env vars that must be set for LWE, as KEY=VALUE strings for use with `env` command. */
  534. function buildLweEnvVars(): string[] {
  535. const vars: string[] = []
  536. vars.push(`LD_LIBRARY_PATH=${getLweLdLibraryPath()}`)
  537. // CEF bundles its own libEGL.so alongside the LWE binary. LD_LIBRARY_PATH includes the
  538. // binary's directory so CEF's EGL shadows Mesa's system EGL, breaking Wayland rendering.
  539. // Force the system EGL via LD_PRELOAD so Mesa's implementation is always used.
  540. const systemEgl = ['/lib64/libEGL.so.1', '/usr/lib64/libEGL.so.1', '/usr/lib/libEGL.so.1']
  541. .find(p => fs.existsSync(p))
  542. if (systemEgl) vars.push(`LD_PRELOAD=${systemEgl}`)
  543. const waylandDisplay = getWaylandDisplay()
  544. if (waylandDisplay) vars.push(`WAYLAND_DISPLAY=${waylandDisplay}`)
  545. // XDG_SESSION_TYPE is how LWE decides between X11 and Wayland drivers
  546. const sessionType = process.env.XDG_SESSION_TYPE
  547. if (sessionType) vars.push(`XDG_SESSION_TYPE=${sessionType}`)
  548. if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
  549. // XDG_RUNTIME_DIR is needed for the Wayland socket
  550. vars.push(`XDG_RUNTIME_DIR=${getXdgRuntimeDir()}`)
  551. return vars
  552. }
  553. // Not every linux-wallpaperengine build has --list-objects/--disable-object/--enable-object
  554. // (they're recent additions), so probe --help once and cache the result rather than assuming.
  555. let objectFlagsSupported: boolean | null = null
  556. function invalidateObjectFlagsSupport(): void {
  557. objectFlagsSupported = null
  558. }
  559. function supportsObjectFlags(): boolean {
  560. if (objectFlagsSupported !== null) return objectFlagsSupported
  561. try {
  562. const binaryPath = getLweBinaryPath()
  563. const envVars = buildLweEnvVars()
  564. const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
  565. timeout: 5_000,
  566. encoding: 'utf8',
  567. maxBuffer: 2 * 1024 * 1024
  568. })
  569. objectFlagsSupported = stdout.includes('--list-objects')
  570. } catch {
  571. objectFlagsSupported = false
  572. }
  573. return objectFlagsSupported
  574. }
  575. // --list-audio-objects/--audio-sensitivity are even more recent than --list-objects, so they get
  576. // their own cached probe rather than assuming a build new enough for one is new enough for both.
  577. let audioSensitivitySupported: boolean | null = null
  578. function invalidateAudioSensitivitySupport(): void {
  579. audioSensitivitySupported = null
  580. }
  581. function supportsAudioSensitivity(): boolean {
  582. if (audioSensitivitySupported !== null) return audioSensitivitySupported
  583. try {
  584. const binaryPath = getLweBinaryPath()
  585. const envVars = buildLweEnvVars()
  586. const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
  587. timeout: 5_000,
  588. encoding: 'utf8',
  589. maxBuffer: 2 * 1024 * 1024
  590. })
  591. audioSensitivitySupported = stdout.includes('--list-audio-objects')
  592. } catch {
  593. audioSensitivitySupported = false
  594. }
  595. return audioSensitivitySupported
  596. }
  597. /** Build the common LWE args (assets dir, screen roots, fps, volume, object overrides). */
  598. function buildLweArgs(
  599. wallpaperPath: string,
  600. options: {
  601. screenRoot?: string
  602. fps?: number
  603. volume?: number
  604. disabledObjects?: string[]
  605. enabledObjects?: string[]
  606. propertyOverrides?: Record<string, string>
  607. scalingMode?: string
  608. zoom?: number
  609. disableParallax?: boolean
  610. cornerColor?: string
  611. speed?: number
  612. audioSensitivity?: Record<string, number>
  613. }
  614. ): string[] {
  615. const args: string[] = []
  616. const assetsDir = findWeAssetsDir()
  617. if (assetsDir) args.push('--assets-dir', assetsDir)
  618. // --screen-root uses layer-shell (Wayland) or root-window overlay (X11)
  619. if (options.screenRoot) {
  620. args.push('--screen-root', options.screenRoot)
  621. } else {
  622. const screens = getConnectedScreens()
  623. for (const s of screens) args.push('--screen-root', s)
  624. }
  625. if (options.fps) args.push('--fps', String(options.fps))
  626. if (options.volume !== undefined) args.push('--volume', String(options.volume))
  627. // Global, not per-wallpaper - pulled from config.service instead of options.
  628. const audioScreen = getAudioScreen()
  629. if (audioScreen) args.push('--audio-screen', audioScreen)
  630. const ambientVolume = getAmbientVolume()
  631. if (ambientVolume !== null) args.push('--ambient-volume', String(ambientVolume))
  632. if (options.scalingMode) args.push('--scaling', options.scalingMode)
  633. if (options.zoom !== undefined) args.push('--zoom', String(options.zoom))
  634. if (options.disableParallax) args.push('--disable-parallax')
  635. if (options.cornerColor) args.push('--corner-color', options.cornerColor)
  636. if (options.speed !== undefined) args.push('--speed', String(options.speed))
  637. if ((options.disabledObjects?.length || options.enabledObjects?.length) && supportsObjectFlags()) {
  638. for (const id of options.disabledObjects ?? []) args.push('--disable-object', id)
  639. for (const id of options.enabledObjects ?? []) args.push('--enable-object', id)
  640. }
  641. for (const [name, value] of Object.entries(options.propertyOverrides ?? {})) {
  642. args.push('--set-property', `${name}=${value}`)
  643. }
  644. if (supportsAudioSensitivity()) {
  645. // Global default (config.service) applies to every audio-reactive object via the "*"
  646. // wildcard; per-wallpaper overrides in options.audioSensitivity are matched first by the
  647. // engine (specific id/name always wins over "*"), so both can be sent unconditionally.
  648. const defaultSensitivity = getDefaultAudioSensitivity()
  649. if (defaultSensitivity !== 1) args.push('--audio-sensitivity', `*=${defaultSensitivity}`)
  650. for (const [id, multiplier] of Object.entries(options.audioSensitivity ?? {})) {
  651. args.push('--audio-sensitivity', `${id}=${multiplier}`)
  652. }
  653. }
  654. args.push(wallpaperPath)
  655. return args
  656. }
  657. /** Control file used for hot-reload signalling. */
  658. function getControlFilePath(): string {
  659. return path.join(getXdgRuntimeDir(), 'lwe-control')
  660. }
  661. /**
  662. * Tracks a still-running LWE process across app restarts. `activeProcess` is an in-memory handle
  663. * that dies with this Electron process, but the LWE child is spawned detached/unref'd so it
  664. * survives independently - after the app is closed and reopened, `activeProcess` comes back null
  665. * even though the OS process is still alive. resolveActivePid() below covers that gap.
  666. */
  667. function getPidFilePath(): string {
  668. return path.join(getXdgRuntimeDir(), 'lwe-pid')
  669. }
  670. function writePidFile(pid: number): void {
  671. try {
  672. fs.writeFileSync(getPidFilePath(), String(pid))
  673. } catch (err) {
  674. console.error('[LWE] Failed to write pid file:', err)
  675. }
  676. }
  677. function clearPidFile(): void {
  678. try {
  679. fs.rmSync(getPidFilePath(), { force: true })
  680. } catch { /* best effort */ }
  681. }
  682. /** True if `pid` is alive and actually looks like a linux-wallpaperengine process (not a recycled PID). */
  683. function isLweProcess(pid: number): boolean {
  684. try {
  685. process.kill(pid, 0)
  686. } catch {
  687. return false
  688. }
  689. try {
  690. const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
  691. return cmdline.includes(LWE_BINARY)
  692. } catch {
  693. // /proc unreadable (e.g. owned by another user) - we already know it's alive, best effort
  694. return true
  695. }
  696. }
  697. /**
  698. * Resolves the PID to signal for hotswap/stop, preferring the in-memory child handle (cheap,
  699. * exact) and falling back to the persisted pid file (recovers tracking after an app restart).
  700. */
  701. function resolveActivePid(): number | null {
  702. if (activeProcess && activeProcess.exitCode === null && activeProcess.pid !== undefined) {
  703. return activeProcess.pid
  704. }
  705. let stored: number | null = null
  706. try {
  707. stored = parseInt(fs.readFileSync(getPidFilePath(), 'utf8').trim(), 10)
  708. } catch {
  709. return null
  710. }
  711. if (!Number.isInteger(stored) || !isLweProcess(stored)) {
  712. clearPidFile()
  713. return null
  714. }
  715. return stored
  716. }
  717. /**
  718. * Pushes a background swap and/or layer/volume/xray/scaling/zoom/parallax changes to an already-
  719. * running instance via the extended key=value control file. All fields are optional and
  720. * independent - only the ones provided get written. All fields for one logical change must go
  721. * through a single call: the engine has only one pending-request slot, so two calls in quick
  722. * succession can race and silently drop whichever one wrote last. disabledObjects/enabledObjects
  723. * being present (even as empty arrays) is what tells the engine to replace its override lists -
  724. * omit both to leave layers untouched. xray is a live-only setting with no launch-time equivalent,
  725. * so it has to be pushed here even right after a fresh launch. cornerColor is a hex
  726. * "RRGGBB"/"RRGGBBAA" string, only visible where the engine's clamp mode is border (the default).
  727. * propertyOverrides sends every key in the map, not just changed ones, since the engine only
  728. * reapplies whatever's currently in its settings map on reload.
  729. */
  730. export function hotswapLweSettings(options: {
  731. path?: string
  732. disabledObjects?: string[]
  733. enabledObjects?: string[]
  734. volume?: number
  735. xray?: boolean
  736. scaling?: string
  737. zoom?: number
  738. disableParallax?: boolean
  739. cornerColor?: string
  740. speed?: number
  741. propertyOverrides?: Record<string, string>
  742. /** Empty string clears the restriction back to "every screen can produce audio" */
  743. audioScreen?: string
  744. ambientVolume?: number
  745. /** Object id (or "*" for every audio-reactive object with no more specific entry) -> multiplier */
  746. audioSensitivity?: Record<string, number>
  747. }): boolean {
  748. const pid = resolveActivePid()
  749. if (pid === null) return false
  750. const lines: string[] = []
  751. if (options.path !== undefined) lines.push(`path=${options.path}`)
  752. if (options.disabledObjects !== undefined || options.enabledObjects !== undefined) {
  753. lines.push('layers=1')
  754. for (const id of options.disabledObjects ?? []) lines.push(`disable-object=${id}`)
  755. for (const id of options.enabledObjects ?? []) lines.push(`enable-object=${id}`)
  756. }
  757. if (options.volume !== undefined) lines.push(`volume=${options.volume}`)
  758. if (options.xray !== undefined) lines.push(`xray=${options.xray ? 'on' : 'off'}`)
  759. if (options.scaling !== undefined) lines.push(`scaling=${options.scaling}`)
  760. if (options.zoom !== undefined) lines.push(`zoom=${options.zoom}`)
  761. if (options.disableParallax !== undefined) lines.push(`disable-parallax=${options.disableParallax ? 'on' : 'off'}`)
  762. if (options.cornerColor !== undefined) lines.push(`corner-color=${options.cornerColor}`)
  763. if (options.speed !== undefined) lines.push(`speed=${options.speed}`)
  764. if (options.audioScreen !== undefined) lines.push(`audio-screen=${options.audioScreen}`)
  765. if (options.ambientVolume !== undefined) lines.push(`ambient-volume=${options.ambientVolume}`)
  766. for (const [name, value] of Object.entries(options.propertyOverrides ?? {})) {
  767. lines.push(`property=${name}=${value}`)
  768. }
  769. for (const [id, multiplier] of Object.entries(options.audioSensitivity ?? {})) {
  770. lines.push(`audio-sensitivity=${id}=${multiplier}`)
  771. }
  772. if (lines.length === 0) return false
  773. try {
  774. fs.writeFileSync(getControlFilePath(), lines.join('\n') + '\n')
  775. process.kill(pid, 'SIGUSR1')
  776. console.log('[LWE] Hotswap settings sent:', lines.join(' '))
  777. return true
  778. } catch (err) {
  779. console.error('[LWE] Hotswap settings failed:', err)
  780. return false
  781. }
  782. }
  783. /** Launch LWE and resolve after 2s if still running; reject if it exits with error (so UI can show message). */
  784. export function launchLweAsync(
  785. wallpaperPath: string,
  786. options: {
  787. screenRoot?: string
  788. fps?: number
  789. volume?: number
  790. disabledObjects?: string[]
  791. enabledObjects?: string[]
  792. propertyOverrides?: Record<string, string>
  793. xrayFullReveal?: boolean
  794. scalingMode?: string
  795. zoom?: number
  796. disableParallax?: boolean
  797. cornerColor?: string
  798. speed?: number
  799. audioSensitivity?: Record<string, number>
  800. } = {}
  801. ): Promise<void> {
  802. // Hot-reload if already running. xray has no launch flag and the running instance keeps its own
  803. // xray state across a background swap, so it needs an explicit push even when off. Scaling/zoom/
  804. // parallax/cornerColor/speed/propertyOverrides do have launch flags, but hot-reload doesn't
  805. // restart the process - the running instance keeps whatever the previous wallpaper set, so they
  806. // need the same explicit push, defaulting to "no override" when the new wallpaper doesn't set them.
  807. const defaultSensitivity = getDefaultAudioSensitivity()
  808. if (
  809. isLweRunning() &&
  810. hotswapLweSettings({
  811. path: wallpaperPath,
  812. xray: options.xrayFullReveal,
  813. scaling: options.scalingMode ?? 'default',
  814. zoom: options.zoom ?? 1,
  815. disableParallax: options.disableParallax ?? false,
  816. cornerColor: options.cornerColor ?? '000000',
  817. speed: options.speed ?? 1,
  818. propertyOverrides: options.propertyOverrides ?? {},
  819. audioSensitivity: {
  820. ...(defaultSensitivity !== 1 ? { '*': defaultSensitivity } : {}),
  821. ...options.audioSensitivity
  822. }
  823. })
  824. ) {
  825. return Promise.resolve()
  826. }
  827. stopLwe()
  828. const binaryPath = getLweBinaryPath()
  829. const lweArgs = buildLweArgs(wallpaperPath, options)
  830. const envVars = buildLweEnvVars()
  831. // Spawn via `env` to set env vars explicitly, matching how LWE works from the terminal.
  832. // The env option on spawn can lose vars when combined with detached/setsid.
  833. const spawnArgs = [...envVars, binaryPath, ...lweArgs]
  834. console.log('[LWE] Binary:', binaryPath)
  835. console.log('[LWE] Args:', lweArgs.join(' '))
  836. console.log('[LWE] Env:', envVars.join(' '))
  837. return new Promise((resolve, reject) => {
  838. const child = spawn('env', spawnArgs, {
  839. stdio: ['ignore', 'pipe', 'pipe'],
  840. detached: true
  841. })
  842. activeProcess = child
  843. if (child.pid !== undefined) writePidFile(child.pid)
  844. const stderrChunks: Buffer[] = []
  845. if (child.stdout) {
  846. child.stdout.on('data', (chunk: Buffer) => {
  847. const msg = chunk.toString('utf8').trim()
  848. if (msg) console.log('[LWE stdout]', msg)
  849. })
  850. }
  851. if (child.stderr) {
  852. child.stderr.on('data', (chunk: Buffer) => {
  853. stderrChunks.push(chunk)
  854. const msg = chunk.toString('utf8').trim()
  855. if (msg) console.log('[LWE stderr]', msg)
  856. })
  857. }
  858. let settled = false
  859. // A rapid stop+relaunch can leave the old process still shutting down (CEF/GL
  860. // teardown isn't instant) when its exit event finally arrives - only clear
  861. // activeProcess if it's still pointing at this child, not a newer one that
  862. // superseded it, otherwise the newer launch's own "still running" resolve
  863. // check below never passes and its promise hangs forever.
  864. const cleanup = () => {
  865. if (activeProcess === child) {
  866. activeProcess = null
  867. clearPidFile()
  868. }
  869. }
  870. child.on('exit', (code) => {
  871. cleanup()
  872. if (settled) return
  873. settled = true
  874. if (code !== 0 && code !== null) {
  875. const stderr = Buffer.concat(stderrChunks).toString('utf8').trim()
  876. const msg = stderr ? stderr.split('\n').slice(0, 3).join('\n') : `Exit code ${code}`
  877. reject(new Error(`linux-wallpaperengine failed: ${msg}`))
  878. } else {
  879. resolve()
  880. }
  881. })
  882. child.on('error', (err) => {
  883. cleanup()
  884. if (!settled) {
  885. settled = true
  886. reject(err)
  887. }
  888. })
  889. child.unref()
  890. // If still running after 2s, consider it started successfully
  891. setTimeout(() => {
  892. if (!settled && activeProcess === child && child.exitCode === null) {
  893. settled = true
  894. if (options.xrayFullReveal) hotswapLweSettings({ xray: true })
  895. resolve()
  896. }
  897. }, 2000)
  898. })
  899. }
  900. // Matches lines like " 123 - Layer Name (image)" printed by --list-objects
  901. const OBJECT_LINE_RE = /^\s*(\S+)\s-\s(.*)\s\((image|particle|text|sound|unknown)\)\s*$/
  902. function parseLweObjectList(output: string): LweSceneObject[] {
  903. const objects: LweSceneObject[] = []
  904. for (const line of output.split('\n')) {
  905. const m = line.match(OBJECT_LINE_RE)
  906. if (m) objects.push({ id: m[1], name: m[2], type: m[3] as LweSceneObject['type'] })
  907. }
  908. return objects
  909. }
  910. // Returns [] for non-scene wallpapers (video/web print no objects)
  911. export async function listLweObjects(wallpaperPath: string): Promise<LweSceneObject[]> {
  912. const status = getLweStatus()
  913. if (!status.installed) throw new Error('linux-wallpaperengine is not installed.')
  914. // Older builds silently ignore unknown flags and fall through to actually rendering the
  915. // wallpaper instead of exiting, so this must never be sent to a binary that doesn't support it.
  916. if (!supportsObjectFlags()) return []
  917. const binaryPath = getLweBinaryPath()
  918. const args = ['--list-objects']
  919. const assetsDir = findWeAssetsDir()
  920. if (assetsDir) args.push('--assets-dir', assetsDir)
  921. args.push(wallpaperPath)
  922. const envVars = buildLweEnvVars()
  923. try {
  924. const { stdout } = await execFileAsync('env', [...envVars, binaryPath, ...args], {
  925. timeout: 15_000,
  926. maxBuffer: 5 * 1024 * 1024,
  927. encoding: 'utf8'
  928. })
  929. return parseLweObjectList(stdout)
  930. } catch (err) {
  931. const e = err as Error & { stdout?: string; stderr?: string }
  932. const msg = (e.stderr || e.stdout || e.message || String(err)).trim().split('\n').slice(0, 3).join('\n')
  933. throw new Error(`Failed to list wallpaper objects: ${msg}`)
  934. }
  935. }
  936. // Matches lines like " 24 - transbig (scale): minvalue=0.9 maxvalue=1.1 frequency=0 smoothing=10"
  937. // printed by --list-audio-objects
  938. const AUDIO_OBJECT_LINE_RE =
  939. /^\s*(\S+)\s-\s(.*)\s\((.+)\):\sminvalue=(\S+)\smaxvalue=(\S+)\sfrequency=(\S+)\ssmoothing=(\S+)\s*$/
  940. function parseLweAudioObjectList(output: string): LweAudioObject[] {
  941. const objects: LweAudioObject[] = []
  942. for (const line of output.split('\n')) {
  943. const m = line.match(AUDIO_OBJECT_LINE_RE)
  944. if (m) {
  945. objects.push({
  946. objectId: m[1],
  947. objectName: m[2],
  948. property: m[3],
  949. minvalue: parseFloat(m[4]),
  950. maxvalue: parseFloat(m[5]),
  951. frequency: parseFloat(m[6]),
  952. smoothing: parseFloat(m[7])
  953. })
  954. }
  955. }
  956. return objects
  957. }
  958. // Returns [] for wallpapers with no audio-reactive scripted properties (most wallpapers)
  959. export async function listLweAudioObjects(wallpaperPath: string): Promise<LweAudioObject[]> {
  960. const status = getLweStatus()
  961. if (!status.installed) throw new Error('linux-wallpaperengine is not installed.')
  962. // Same reasoning as supportsObjectFlags(): older builds silently ignore unknown flags and fall
  963. // through to actually rendering the wallpaper instead of exiting.
  964. if (!supportsAudioSensitivity()) return []
  965. const binaryPath = getLweBinaryPath()
  966. const args = ['--list-audio-objects']
  967. const assetsDir = findWeAssetsDir()
  968. if (assetsDir) args.push('--assets-dir', assetsDir)
  969. args.push(wallpaperPath)
  970. const envVars = buildLweEnvVars()
  971. try {
  972. const { stdout } = await execFileAsync('env', [...envVars, binaryPath, ...args], {
  973. timeout: 15_000,
  974. maxBuffer: 5 * 1024 * 1024,
  975. encoding: 'utf8'
  976. })
  977. return parseLweAudioObjectList(stdout)
  978. } catch (err) {
  979. const e = err as Error & { stdout?: string; stderr?: string }
  980. const msg = (e.stderr || e.stdout || e.message || String(err)).trim().split('\n').slice(0, 3).join('\n')
  981. throw new Error(`Failed to list audio-reactive objects: ${msg}`)
  982. }
  983. }
  984. // Parses --list-properties output, e.g.:
  985. // background_edit_brightness - slider
  986. // Text: Brightness
  987. // Min: 0
  988. // Max: 200
  989. // Step: 0
  990. // Value: 100.000000
  991. //
  992. // clock_font - combo
  993. // Text: ui_clock_font
  994. // Value: impact
  995. // Values:
  996. // segoe ui = ui_clock_font_4
  997. // Combo option values (before " = ") can contain spaces (e.g. font names), so that
  998. // line can't be split on whitespace - only on the literal " = " separator.
  999. const PROPERTY_HEADER_RE =
  1000. /^(\S+) - (slider|boolean|color|combo|text|scene texture|file|textinput)$/
  1001. const PROPERTY_DETAIL_RE = /^\t(Text|Value|Min|Max|Step): (.*)$/
  1002. const PROPERTY_COMBO_OPTION_RE = /^\t\t(.+?) = (.*)$/
  1003. function parseLweProperties(output: string): LweProperty[] {
  1004. const properties: LweProperty[] = []
  1005. let current: LweProperty | null = null
  1006. for (const line of output.split('\n')) {
  1007. const header = line.match(PROPERTY_HEADER_RE)
  1008. if (header) {
  1009. if (current) properties.push(current)
  1010. const type = header[2] === 'scene texture' ? 'scene-texture' : (header[2] as LweProperty['type'])
  1011. current = { name: header[1], type, value: '' }
  1012. continue
  1013. }
  1014. if (!current) continue
  1015. const comboOption = line.match(PROPERTY_COMBO_OPTION_RE)
  1016. if (comboOption) {
  1017. current.options ??= []
  1018. current.options.push({ value: comboOption[1], label: comboOption[2] })
  1019. continue
  1020. }
  1021. const detail = line.match(PROPERTY_DETAIL_RE)
  1022. if (detail) {
  1023. const [, key, value] = detail
  1024. if (key === 'Text') current.text = value
  1025. else if (key === 'Value') current.value = value
  1026. else if (key === 'Min') current.min = parseFloat(value)
  1027. else if (key === 'Max') current.max = parseFloat(value)
  1028. else if (key === 'Step') current.step = parseFloat(value)
  1029. }
  1030. }
  1031. if (current) properties.push(current)
  1032. return properties
  1033. }
  1034. export async function listLweProperties(wallpaperPath: string): Promise<LweProperty[]> {
  1035. const status = getLweStatus()
  1036. if (!status.installed) throw new Error('linux-wallpaperengine is not installed.')
  1037. const binaryPath = getLweBinaryPath()
  1038. const args = ['--list-properties']
  1039. const assetsDir = findWeAssetsDir()
  1040. if (assetsDir) args.push('--assets-dir', assetsDir)
  1041. args.push(wallpaperPath)
  1042. const envVars = buildLweEnvVars()
  1043. try {
  1044. const { stdout } = await execFileAsync('env', [...envVars, binaryPath, ...args], {
  1045. timeout: 15_000,
  1046. maxBuffer: 5 * 1024 * 1024,
  1047. encoding: 'utf8'
  1048. })
  1049. return parseLweProperties(stdout)
  1050. } catch (err) {
  1051. const e = err as Error & { stdout?: string; stderr?: string }
  1052. const msg = (e.stderr || e.stdout || e.message || String(err)).trim().split('\n').slice(0, 3).join('\n')
  1053. throw new Error(`Failed to list wallpaper properties: ${msg}`)
  1054. }
  1055. }
  1056. export function stopLwe(): void {
  1057. const pid = resolveActivePid()
  1058. if (pid !== null) {
  1059. try {
  1060. process.kill(pid, 'SIGTERM')
  1061. } catch { /* already dead */ }
  1062. }
  1063. activeProcess = null
  1064. clearPidFile()
  1065. }
  1066. export function isLweRunning(): boolean {
  1067. return resolveActivePid() !== null
  1068. }
  1069. /** Force-kill every linux-wallpaperengine process on the system, including ones this app isn't tracking. */
  1070. export async function killAllLweProcesses(): Promise<{ ok: boolean; message: string }> {
  1071. activeProcess = null
  1072. clearPidFile()
  1073. try {
  1074. const { stdout } = await execFileAsync('pkill', ['-9', '-f', '-c', LWE_BINARY])
  1075. const count = parseInt(stdout.trim(), 10) || 0
  1076. return { ok: true, message: `Killed ${count} process${count === 1 ? '' : 'es'}.` }
  1077. } catch (err) {
  1078. const code = (err as { code?: number }).code
  1079. // pkill exits 1 when nothing matched, that's not a failure here
  1080. if (code === 1) {
  1081. return { ok: true, message: 'No running processes found.' }
  1082. }
  1083. return { ok: false, message: `Failed to kill processes: ${(err as Error).message}` }
  1084. }
  1085. }