lwe.service.ts 21 KB

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