lwe.service.ts 53 KB

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