wallpaper.service.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { execFile } from 'child_process'
  2. import { promisify } from 'util'
  3. import * as os from 'os'
  4. import * as fs from 'fs'
  5. import * as path from 'path'
  6. import { detectDisplayServer, detectDesktopEnv, isCommandAvailable } from '../utils/platform'
  7. import type { WallpaperBackend, WallpaperEnvironment, WallpaperMeta } from '@shared/types'
  8. import { getLweStatus, launchLweAsync } from './lwe.service'
  9. import { getDefaultFps, getRecommendedFpsEnabled, getRecommendedWebFpsEnabled } from './config.service'
  10. import { findWallpaperVideoFile, getVideoFps } from '../utils/video'
  11. import { RECOMMENDED_WEB_FPS } from '@shared/constants'
  12. const execFileAsync = promisify(execFile)
  13. let envCache: WallpaperEnvironment | null = null
  14. export function invalidateEnvCache(): void {
  15. envCache = null
  16. }
  17. export function detectEnvironment(): WallpaperEnvironment {
  18. if (envCache) return envCache
  19. const platform = os.platform()
  20. const displayServer = detectDisplayServer()
  21. const desktopEnv = detectDesktopEnv()
  22. const available: WallpaperBackend[] = []
  23. if (platform === 'win32') {
  24. available.push('windows')
  25. } else if (platform === 'darwin') {
  26. available.push('macos')
  27. } else {
  28. if (isCommandAvailable('swww')) available.push('swww')
  29. if (isCommandAvailable('swaybg')) available.push('swaybg')
  30. if (isCommandAvailable('feh')) available.push('feh')
  31. if (isCommandAvailable('xwallpaper')) available.push('xwallpaper')
  32. if (desktopEnv === 'gnome') available.push('gsettings')
  33. if (desktopEnv === 'kde' && isCommandAvailable('qdbus')) available.push('qdbus')
  34. }
  35. let recommended: WallpaperBackend = 'auto'
  36. if (platform === 'win32') recommended = 'windows'
  37. else if (platform === 'darwin') recommended = 'macos'
  38. else if (desktopEnv === 'gnome') recommended = 'gsettings'
  39. else if (desktopEnv === 'kde') recommended = 'qdbus'
  40. else if (displayServer === 'wayland' && available.includes('swww')) recommended = 'swww'
  41. else if (available.includes('feh')) recommended = 'feh'
  42. envCache = {
  43. displayServer,
  44. desktopEnv,
  45. availableBackends: available,
  46. recommendedBackend: recommended,
  47. linuxWallpaperEngine: getLweStatus()
  48. }
  49. return envCache
  50. }
  51. export async function applyWallpaper(imagePath: string, backend?: WallpaperBackend): Promise<void> {
  52. const platform = os.platform()
  53. if (platform === 'linux') {
  54. await applyLinux(imagePath, backend)
  55. } else if (platform === 'darwin') {
  56. await applyMacOS(imagePath)
  57. } else if (platform === 'win32') {
  58. await applyWindows(imagePath)
  59. } else {
  60. throw new Error(`Unsupported platform: ${platform}`)
  61. }
  62. }
  63. async function applyLinux(imagePath: string, preferredBackend?: WallpaperBackend): Promise<void> {
  64. if (preferredBackend && preferredBackend !== 'auto') {
  65. await applyWithBackend(preferredBackend, imagePath)
  66. return
  67. }
  68. const backend = pickLinuxBackend()
  69. if (!backend) {
  70. throw new Error(
  71. 'No supported wallpaper backend found. Please install swww (Wayland) or feh (X11).'
  72. )
  73. }
  74. await applyWithBackend(backend, imagePath)
  75. }
  76. function pickLinuxBackend(): WallpaperBackend | null {
  77. const de = detectDesktopEnv()
  78. if (de === 'gnome') return 'gsettings'
  79. if (de === 'kde' && isCommandAvailable('qdbus')) return 'qdbus'
  80. if (detectDisplayServer() === 'wayland') {
  81. if (isCommandAvailable('swww')) return 'swww'
  82. if (isCommandAvailable('swaybg')) return 'swaybg'
  83. }
  84. if (isCommandAvailable('feh')) return 'feh'
  85. if (isCommandAvailable('xwallpaper')) return 'xwallpaper'
  86. return null
  87. }
  88. async function applyWithBackend(backend: WallpaperBackend, imagePath: string): Promise<void> {
  89. switch (backend) {
  90. case 'swww':
  91. await execFileAsync('swww', ['img', imagePath, '--transition-type', 'fade'])
  92. break
  93. case 'swaybg':
  94. await execFileAsync('swaybg', ['-i', imagePath, '-m', 'fill'])
  95. break
  96. case 'feh':
  97. await execFileAsync('feh', ['--bg-fill', imagePath])
  98. break
  99. case 'xwallpaper':
  100. await execFileAsync('xwallpaper', ['--zoom', imagePath])
  101. break
  102. case 'gsettings':
  103. await execFileAsync('gsettings', [
  104. 'set',
  105. 'org.gnome.desktop.background',
  106. 'picture-uri',
  107. `file://${imagePath}`
  108. ])
  109. break
  110. case 'qdbus': {
  111. const script = `
  112. var allDesktops = desktops();
  113. for (var i = 0; i < allDesktops.length; i++) {
  114. var d = allDesktops[i];
  115. d.wallpaperPlugin = 'org.kde.image';
  116. d.currentConfigGroup = ['Wallpaper', 'org.kde.image', 'General'];
  117. d.writeConfig('Image', 'file://${imagePath}');
  118. }
  119. `
  120. await execFileAsync('qdbus', [
  121. 'org.kde.plasmashell',
  122. '/PlasmaShell',
  123. 'org.kde.PlasmaShell.evaluateScript',
  124. script
  125. ])
  126. break
  127. }
  128. case 'windows':
  129. await applyWindows(imagePath)
  130. break
  131. case 'macos':
  132. await applyMacOS(imagePath)
  133. break
  134. default:
  135. throw new Error(`Unknown backend: ${backend}`)
  136. }
  137. }
  138. async function applyMacOS(imagePath: string): Promise<void> {
  139. const script = `tell application "Finder" to set desktop picture to POSIX file "${imagePath}"`
  140. await execFileAsync('osascript', ['-e', script])
  141. }
  142. async function applyWindows(imagePath: string): Promise<void> {
  143. // Pass path via environment variable to avoid injection risk from special characters
  144. const script = `
  145. Add-Type -TypeDefinition @'
  146. using System.Runtime.InteropServices;
  147. public class Wallpaper {
  148. [DllImport("user32.dll", CharSet=CharSet.Auto)]
  149. public static extern int SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
  150. }
  151. '@
  152. [Wallpaper]::SystemParametersInfo(20, 0, $env:WE_WALLPAPER_PATH, 3)
  153. `
  154. await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
  155. env: { ...process.env, WE_WALLPAPER_PATH: imagePath }
  156. })
  157. }
  158. function findWallpaperImage(dirPath: string): string {
  159. if (!fs.existsSync(dirPath)) {
  160. throw new Error(`Directory not found: ${dirPath}`)
  161. }
  162. if (!fs.statSync(dirPath).isDirectory()) {
  163. return dirPath
  164. }
  165. const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
  166. const files = fs.readdirSync(dirPath)
  167. const preview = files.find((f) => f.toLowerCase().startsWith('preview'))
  168. if (preview && imageExtensions.includes(path.extname(preview).toLowerCase())) {
  169. return path.join(dirPath, preview)
  170. }
  171. const image = files.find((f) => imageExtensions.includes(path.extname(f).toLowerCase()))
  172. if (image) return path.join(dirPath, image)
  173. throw new Error(`No image found in ${dirPath}`)
  174. }
  175. /**
  176. * Apply a library wallpaper: renders animated/scene/web/video wallpapers via
  177. * linux-wallpaperengine when available, otherwise falls back to a static image.
  178. * Shared by the manual "apply" IPC handler and the playlist playback engine.
  179. */
  180. export async function applyWallpaperMeta(
  181. wallpaper: WallpaperMeta,
  182. options: { fps?: number; volume?: number; backend?: WallpaperBackend } = {}
  183. ): Promise<string> {
  184. if (!wallpaper.localPath) throw new Error(`Wallpaper ${wallpaper.id} has no local path`)
  185. const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
  186. const lwe = getLweStatus()
  187. if (isAnimated && os.platform() === 'linux' && lwe.installed) {
  188. let fps = options.fps ?? wallpaper.fpsOverride
  189. if (fps === undefined && wallpaper.type === 'video' && getRecommendedFpsEnabled()) {
  190. const videoFile = findWallpaperVideoFile(wallpaper.localPath, wallpaper.file)
  191. if (videoFile) fps = await getVideoFps(videoFile)
  192. }
  193. if (fps === undefined && wallpaper.type === 'web' && getRecommendedWebFpsEnabled()) {
  194. fps = RECOMMENDED_WEB_FPS
  195. }
  196. fps = fps ?? getDefaultFps() ?? undefined
  197. const volume = options.volume ?? wallpaper.volumeOverride
  198. await launchLweAsync(wallpaper.localPath, {
  199. fps,
  200. volume,
  201. disabledObjects: wallpaper.disabledObjects,
  202. enabledObjects: wallpaper.enabledObjects,
  203. propertyOverrides: wallpaper.propertyOverrides,
  204. // Explicit even when off: xray state lives on the running LWE process, not per-wallpaper,
  205. // so a hot-reload into a wallpaper with xray off must actively clear a previous wallpaper's "on".
  206. xrayFullReveal: wallpaper.xrayFullReveal ?? false,
  207. scalingMode: wallpaper.scalingMode,
  208. zoom: wallpaper.zoom,
  209. // Explicit even when off, same reasoning as xrayFullReveal above.
  210. disableParallax: wallpaper.disableParallax ?? false,
  211. cornerColor: wallpaper.cornerColor,
  212. speed: wallpaper.playbackSpeed,
  213. audioSensitivity: wallpaper.audioSensitivity
  214. })
  215. return wallpaper.localPath
  216. }
  217. // Static fallback: set the preview image as a plain wallpaper
  218. const appliedPath = findWallpaperImage(wallpaper.localPath)
  219. await applyWallpaper(appliedPath, options.backend)
  220. return appliedPath
  221. }