瀏覽代碼

added support for web wallpapers, fixed plenty of issues

UwU 1 月之前
父節點
當前提交
ff8810bf11

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "we-manager",
-  "version": "0.3.2",
+  "version": "0.4.0",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
   "author": "UwU <260361707+WallpaperEngineLover@users.noreply.github.com>",

+ 1 - 1
src/main/ipc/backup.handlers.ts

@@ -17,7 +17,7 @@ async function backupOne(id: string, win: BrowserWindow): Promise<{ ok: boolean;
   if (getAutoUnsubscribeAfterBackup()) {
     await steam.unsubscribeFromItem(BigInt(id))
     try {
-      fs.rmSync(wallpaper.localPath, { recursive: true, force: true })
+      await fs.promises.rm(wallpaper.localPath, { recursive: true, force: true })
     } catch {
       /* ignore */
     }

+ 8 - 0
src/main/ipc/config.handlers.ts

@@ -8,6 +8,8 @@ import {
   setDefaultFps,
   getRecommendedFpsEnabled,
   setRecommendedFpsEnabled,
+  getRecommendedWebFpsEnabled,
+  setRecommendedWebFpsEnabled,
   getLweRepoUrl,
   getLweRepoBranch,
   setLweRepo,
@@ -44,6 +46,7 @@ export function registerConfigHandlers(): void {
     isConfigured: isWorkshopPathConfigured(),
     defaultFps: getDefaultFps(),
     recommendedFpsEnabled: getRecommendedFpsEnabled(),
+    recommendedWebFpsEnabled: getRecommendedWebFpsEnabled(),
     lweRepoUrl: getLweRepoUrl(),
     lweRepoBranch: getLweRepoBranch(),
     lweCmakeArgs: getLweCmakeArgs(),
@@ -107,6 +110,11 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.CONFIG_SET_RECOMMENDED_WEB_FPS, (_e, enabled: boolean) => {
+    setRecommendedWebFpsEnabled(enabled)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_LWE_REPO, (_e, url: string | null, branch: string | null) => {
     setLweRepo(url, branch)
     return { ok: true }

+ 24 - 8
src/main/ipc/steam.handlers.ts

@@ -41,6 +41,9 @@ export function registerSteamHandlers(win: BrowserWindow): void {
 
   ipcMain.handle(IpcChannels.STEAM_SUBSCRIBE, async (_e, itemId: string) => {
     await steam.subscribeToItem(BigInt(itemId))
+    // Steam doesn't always actually start fetching content just because we subscribed - see
+    // downloadItem()'s comment. This is the reliable kick.
+    steam.downloadItem(BigInt(itemId))
     startDownloadPoll(win, itemId)
     return { ok: true }
   })
@@ -49,14 +52,27 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     const wallpaper = library.getWallpaper(itemId)
     await steam.unsubscribeFromItem(BigInt(itemId))
     if (wallpaper?.localPath) {
-      try { fs.rmSync(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
+      // async: an unresponsive mount here must not block the main process (and every other
+      // pending IPC call) while it deletes
+      try { await fs.promises.rm(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
     }
-    if (wallpaper?.backedUp && backup.isBackedUp(itemId)) {
-      library.updateWallpaper(itemId, {
-        source: 'backup',
-        localPath: backup.getBackupDir(itemId),
-        subscribed: false
-      })
+    // Trust the stored flag alone: it's set once, when a backup actually succeeds. Re-checking
+    // the filesystem here (backup.isBackedUp) can disagree with it - e.g. if the configured
+    // backup folder changed since - and must never be allowed to turn "this is backed up" into
+    // "delete the library entry".
+    if (wallpaper?.backedUp) {
+      // getBackupDir() throws if the backup path setting was since cleared entirely - still
+      // must not fall through to deleting a wallpaper we know is backed up.
+      try {
+        library.updateWallpaper(itemId, {
+          source: 'backup',
+          localPath: backup.getBackupDir(itemId),
+          subscribed: false
+        })
+      } catch (err) {
+        library.updateWallpaper(itemId, { subscribed: false })
+        console.error('[Steam] Failed to resolve backup dir on unsubscribe:', err)
+      }
     } else {
       library.deleteWallpaper(itemId)
     }
@@ -67,7 +83,7 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     const wallpaper = library.getWallpaper(itemId)
     await steam.unsubscribeFromItem(BigInt(itemId))
     if (wallpaper?.localPath) {
-      try { fs.rmSync(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
+      try { await fs.promises.rm(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
     }
     if (wallpaper) {
       library.updateWallpaper(itemId, { downloading: true, downloadFailed: false })

+ 13 - 0
src/main/services/config.service.ts

@@ -18,6 +18,7 @@ interface AppConfig {
   autostartPlaylistId: string | null
   killLweOnQuit: boolean
   recommendedFpsEnabled: boolean
+  recommendedWebFpsEnabled: boolean
 }
 
 const store = new Store<AppConfig>({
@@ -26,6 +27,7 @@ const store = new Store<AppConfig>({
     workshopPath: null,
     defaultFps: null,
     recommendedFpsEnabled: false,
+    recommendedWebFpsEnabled: false,
     lweRepoUrl: null,
     lweRepoBranch: null,
     lweCmakeArgs: null,
@@ -125,6 +127,17 @@ export function setRecommendedFpsEnabled(enabled: boolean): void {
   store.set('recommendedFpsEnabled', enabled)
 }
 
+/** When enabled, web wallpapers without a manual FPS override launch at RECOMMENDED_WEB_FPS
+ *  instead of the default limit - CEF paints internally at that rate regardless, so a lower
+ *  default just throttles how often the engine displays what CEF already rendered. */
+export function getRecommendedWebFpsEnabled(): boolean {
+  return store.get('recommendedWebFpsEnabled')
+}
+
+export function setRecommendedWebFpsEnabled(enabled: boolean): void {
+  store.set('recommendedWebFpsEnabled', enabled)
+}
+
 /** Custom linux-wallpaperengine repo (git URL or local path); null = official repo. */
 export function getLweRepoUrl(): string | null {
   return store.get('lweRepoUrl')

+ 50 - 4
src/main/services/library.service.ts

@@ -12,7 +12,13 @@ import {
   getUnavailableWorkshopItems
 } from './workshop.service'
 import { findAndMutate } from '../utils/collections'
-import { isSteamRunning, getItemDownloadStatus, getSubscribedItems } from './steam.service'
+import {
+  isSteamRunning,
+  getItemDownloadStatus,
+  getSubscribedItems,
+  isItemStuckNeverDownloaded,
+  downloadItem
+} from './steam.service'
 
 interface LibraryStore {
   wallpapers: Record<string, WallpaperMeta>
@@ -204,6 +210,20 @@ function deriveRatingFromTags(tags: string[]): ContentRating | undefined {
   return match ? normalizeRating(match) : undefined
 }
 
+const RATING_RESTRICTIVENESS: Record<ContentRating, number> = {
+  uncategorized: 0,
+  everyone: 1,
+  questionable: 2,
+  mature: 3
+}
+
+// project.json's "contentrating" field and its "tags" array can disagree (an author can tag
+// something "Mature" without setting the formal rating) - always trust the stricter one.
+function stricterRating(a: ContentRating, b?: ContentRating): ContentRating {
+  if (!b) return a
+  return RATING_RESTRICTIVENESS[b] > RATING_RESTRICTIVENESS[a] ? b : a
+}
+
 async function safeGetWorkshopTags(ids: string[]): Promise<Map<string, string[]>> {
   try {
     return await getWorkshopTags(ids)
@@ -265,7 +285,7 @@ function buildMeta(
     id: workshopId,
     title: pj?.title ?? `Wallpaper ${workshopId}`,
     type: normalizeType(pj?.type),
-    contentRating: normalizeRating(pj?.contentrating),
+    contentRating: stricterRating(normalizeRating(pj?.contentrating), deriveRatingFromTags(pj?.tags ?? [])),
     description: pj?.description,
     previewLocal: previewPath && fs.existsSync(previewPath) ? previewPath : undefined,
     previewUrl: undefined,
@@ -387,10 +407,14 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
     toRebuild.push({ entry, localPath, pj })
   }
 
-  // Items Steam still lists as subscribed but that never got a local folder
-  // at all (the download failed before writing anything, or hasn't started)
+  // Items Steam still lists as subscribed but that never got a local folder at all (the download
+  // failed before writing anything, or Steam never actually started it - see
+  // isItemStuckNeverDownloaded, this is more common than it sounds).
   for (const id of subscribedIds) {
     if (onDisk.has(id)) continue
+    if (isSteamRunning() && isItemStuckNeverDownloaded(BigInt(id))) {
+      downloadItem(BigInt(id))
+    }
     incomplete.push({ id, localPath: path.join(workshopPath, id) })
   }
 
@@ -452,6 +476,28 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
   for (const id of cacheHits) {
     const seconds = timesUpdated.get(id)
     const authorId = authors.get(id)
+
+    // A time_updated bump means the author changed something on the Workshop side
+    // (tags, content rating, a republish) - re-read project.json instead of just
+    // touching updatedAt, otherwise a corrected rating/genre never leaves the cache
+    // until the item is removed and reimported.
+    const localPath = wallpapers[id].localPath
+    if (seconds !== undefined && wallpapers[id].updatedAt !== seconds * 1000 && localPath) {
+      const pj = readProjectJson(localPath)
+      if (pj) {
+        wallpapers[id] = buildMeta(
+          id,
+          localPath,
+          pj,
+          wallpapers[id],
+          seconds,
+          authorId ?? wallpapers[id].authorSteamId
+        )
+        resynced = true
+        continue
+      }
+    }
+
     const patch: Partial<WallpaperMeta> = {}
     if (seconds !== undefined && wallpapers[id].updatedAt !== seconds * 1000) {
       patch.updatedAt = seconds * 1000

+ 27 - 4
src/main/services/steam.service.ts

@@ -33,6 +33,17 @@ export async function subscribeToItem(itemId: bigint): Promise<void> {
   await getClient().workshop.subscribe(itemId)
 }
 
+// Steam's subscribe doesn't reliably auto-download (seen with items subscribed via the Workshop
+// website rather than the Steam client) - items can sit at bare "Subscribed" forever. This is the
+// reliable kick; a no-op if already installed or downloading.
+export function downloadItem(itemId: bigint, highPriority = true): boolean {
+  try {
+    return getClient().workshop.download(itemId, highPriority)
+  } catch {
+    return false
+  }
+}
+
 export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
   await getClient().workshop.unsubscribe(itemId)
 }
@@ -178,21 +189,33 @@ export function getItemState(itemId: bigint): number {
 }
 
 // EItemState flags from the Steamworks SDK (isteamugc.h)
+const ITEM_STATE_INSTALLED = 4
 const ITEM_STATE_NEEDS_UPDATE = 8
 const ITEM_STATE_DOWNLOADING = 16
 const ITEM_STATE_DOWNLOAD_PENDING = 32
 
+// True for a subscribed item Steam has never actually started fetching - not installed, not
+// mid-transfer, not even flagged as needing an update. See downloadItem() above.
+export function isItemStuckNeverDownloaded(itemId: bigint): boolean {
+  const state = getItemState(itemId)
+  const installed = (state & ITEM_STATE_INSTALLED) !== 0
+  const inProgress = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING | ITEM_STATE_NEEDS_UPDATE)) !== 0
+  return !installed && !inProgress
+}
+
 export function isItemDownloading(itemId: bigint): boolean {
   const state = getItemState(itemId)
   return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
 }
 
-// NeedsUpdate stays set while Steam hasn't caught the local copy up to the
-// server version. If it's set but nothing is actively downloading/pending,
-// Steam gave up on the transfer (e.g. it errored out or the disk filled up).
+// NeedsUpdate stays set while Steam hasn't caught the local copy up to the server version. If
+// it's set but nothing is actively downloading/pending, Steam gave up on the transfer (e.g. it
+// errored out or the disk filled up). A never-downloaded item reports neither bit - treated as
+// "downloading" since the caller (scanLibrary) just kicked one off for it.
 export function getItemDownloadStatus(itemId: bigint): { downloading: boolean; failed: boolean } {
   const state = getItemState(itemId)
-  const downloading = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
+  const downloading =
+    (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0 || isItemStuckNeverDownloaded(itemId)
   const failed = !downloading && (state & ITEM_STATE_NEEDS_UPDATE) !== 0
   return { downloading, failed }
 }

+ 5 - 1
src/main/services/wallpaper.service.ts

@@ -6,8 +6,9 @@ import * as path from 'path'
 import { detectDisplayServer, detectDesktopEnv, isCommandAvailable } from '../utils/platform'
 import type { WallpaperBackend, WallpaperEnvironment, WallpaperMeta } from '@shared/types'
 import { getLweStatus, launchLweAsync } from './lwe.service'
-import { getDefaultFps, getRecommendedFpsEnabled } from './config.service'
+import { getDefaultFps, getRecommendedFpsEnabled, getRecommendedWebFpsEnabled } from './config.service'
 import { findWallpaperVideoFile, getVideoFps } from '../utils/video'
+import { RECOMMENDED_WEB_FPS } from '@shared/constants'
 
 const execFileAsync = promisify(execFile)
 
@@ -214,6 +215,9 @@ export async function applyWallpaperMeta(
       const videoFile = findWallpaperVideoFile(wallpaper.localPath, wallpaper.file)
       if (videoFile) fps = await getVideoFps(videoFile)
     }
+    if (fps === undefined && wallpaper.type === 'web' && getRecommendedWebFpsEnabled()) {
+      fps = RECOMMENDED_WEB_FPS
+    }
     fps = fps ?? getDefaultFps() ?? undefined
     const volume = options.volume ?? wallpaper.volumeOverride
     await launchLweAsync(wallpaper.localPath, {

+ 3 - 0
src/preload/index.ts

@@ -93,6 +93,7 @@ const api = {
       isConfigured: boolean
       defaultFps: number | null
       recommendedFpsEnabled: boolean
+      recommendedWebFpsEnabled: boolean
       lweRepoUrl: string | null
       lweRepoBranch: string | null
       lweCmakeArgs: string | null
@@ -113,6 +114,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_FPS, fps),
     setRecommendedFpsEnabled: (enabled: boolean): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_RECOMMENDED_FPS, enabled),
+    setRecommendedWebFpsEnabled: (enabled: boolean): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_RECOMMENDED_WEB_FPS, enabled),
     setLweRepo: (url: string | null, branch: string | null): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_LWE_REPO, url, branch),
     setLweCmakeArgs: (args: string | null): Promise<{ ok: boolean }> =>

+ 10 - 0
src/renderer/src/App.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, Component, type ReactNode } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
 import Sidebar from './components/layout/Sidebar'
 import WorkshopBrowser from './components/workshop/WorkshopBrowser'
 import LibraryView from './components/library/LibraryView'
@@ -34,6 +35,7 @@ export default function App() {
   const [setupDone, setSetupDone] = useState<boolean | null>(null)
   const [defaultPath, setDefaultPath] = useState('')
   const [creatorFilter, setCreatorFilter] = useState<string | null>(null)
+  const queryClient = useQueryClient()
 
   function browseCreator(steamId: string) {
     setCreatorFilter(steamId)
@@ -47,6 +49,14 @@ export default function App() {
     })
   }, [])
 
+  // Subscribed here (rather than in SettingsView) so an in-progress LWE build/install survives
+  // navigating to another tab and back - SettingsView unmounts on navigation, App never does.
+  useEffect(() => {
+    return window.electronAPI.on.lweInstallProgress((progress) => {
+      queryClient.setQueryData(['lwe-install-progress'], progress)
+    })
+  }, [queryClient])
+
   if (setupDone === null) return null // loading
 
   if (!setupDone) {

+ 102 - 6
src/renderer/src/components/common/DetailSidebar.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, useRef } from 'react'
 import { useQuery, useQueryClient } from '@tanstack/react-query'
 import {
   X,
@@ -29,7 +29,8 @@ import {
   ZoomOut,
   Move3d,
   Palette,
-  Gauge
+  Gauge,
+  Zap
 } from 'lucide-react'
 import clsx from 'clsx'
 import { openWorkshopPage, openProfilePage, isWorkshopId } from '../../utils/steam'
@@ -356,6 +357,19 @@ export default function DetailSidebar({
     }
   }
 
+  const livePreviewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  // Pushes a value live while a slider is being dragged, debounced so a fast drag doesn't flood
+  // the control-file/SIGUSR1 channel. Doesn't touch the library store - mouseup/touchend/keyup
+  // still do that persisted commit, which also re-pushes the final value.
+  function previewLive(patch: { volume?: number; zoom?: number; speed?: number }) {
+    if (!isActive) return
+    if (livePreviewTimer.current) clearTimeout(livePreviewTimer.current)
+    livePreviewTimer.current = setTimeout(() => {
+      window.electronAPI.lwe.hotswapSettings(patch)
+    }, 80)
+  }
+
   async function toggleObject(objId: string) {
     if (togglingObjectId) return
     const disabled = new Set(disabledObjects)
@@ -440,6 +454,23 @@ export default function DetailSidebar({
     }
   }
 
+  const [fpsDraft, setFpsDraft] = useState<number | null>(null)
+  const [isCommittingFps, setIsCommittingFps] = useState(false)
+  const fps = fpsDraft ?? libraryMeta?.fpsOverride ?? null
+
+  // fps isn't part of hotswapSettings' payload, so persistAndMaybeRelaunch's hotswap push is
+  // always a no-op for this patch and it falls through to its stop+relaunch fallback - correct
+  // here, since the FPS cap is baked into the render loop and can't be changed live.
+  async function commitFps(value: number | null) {
+    setIsCommittingFps(true)
+    try {
+      await persistAndMaybeRelaunch({ fpsOverride: value ?? undefined })
+    } finally {
+      setFpsDraft(null)
+      setIsCommittingFps(false)
+    }
+  }
+
   const [speedDraft, setSpeedDraft] = useState<number | null>(null)
   const [isCommittingSpeed, setIsCommittingSpeed] = useState(false)
   const speedPercent = speedDraft ?? Math.round((libraryMeta?.playbackSpeed ?? 1) * 100)
@@ -682,7 +713,11 @@ export default function DetailSidebar({
               step={1}
               value={zoomPercent}
               disabled={isCommittingZoom}
-              onChange={(e) => setZoomDraft(Number(e.target.value))}
+              onChange={(e) => {
+                const v = Number(e.target.value)
+                setZoomDraft(v)
+                previewLive({ zoom: v / 100 })
+              }}
               onMouseUp={(e) => commitZoom(Number(e.currentTarget.value))}
               onTouchEnd={(e) => commitZoom(Number(e.currentTarget.value))}
               onKeyUp={(e) => commitZoom(Number(e.currentTarget.value))}
@@ -691,6 +726,40 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {localPath && lweInstalled && libraryMeta?.type && libraryMeta.type !== 'application' && (
+          <div className="border-t border-white/5 pt-3">
+            <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
+              <Zap size={12} />
+              <span className="flex-1 text-left">FPS limit</span>
+              {isCommittingFps && <Loader2 size={11} className="animate-spin" />}
+              <input
+                type="number"
+                min={1}
+                max={240}
+                step={1}
+                placeholder="default"
+                value={fps ?? ''}
+                disabled={isCommittingFps}
+                onChange={(e) => setFpsDraft(e.target.value === '' ? null : Number(e.target.value))}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter') e.currentTarget.blur()
+                }}
+                onBlur={(e) => {
+                  const raw = e.currentTarget.value.trim()
+                  if (raw === '') {
+                    commitFps(null)
+                    return
+                  }
+                  const parsed = Number(raw)
+                  commitFps(Number.isFinite(parsed) ? Math.min(240, Math.max(1, parsed)) : fps)
+                }}
+                title="Overrides the app-wide default FPS for this wallpaper only - changes require a relaunch, not live. Empty = use the default."
+                className="w-14 rounded bg-white/5 px-1 py-0.5 text-right normal-case text-gray-400 outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-50"
+              />
+            </div>
+          </div>
+        )}
+
         {localPath && lweInstalled && libraryMeta?.type && libraryMeta.type !== 'application' && (
           <div className="border-t border-white/5 pt-3">
             <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
@@ -702,7 +771,26 @@ export default function DetailSidebar({
                 </span>
               )}
               {isCommittingSpeed && <Loader2 size={11} className="animate-spin" />}
-              <span className="normal-case text-gray-400">{speedPercent}%</span>
+              <input
+                type="number"
+                min={10}
+                max={200}
+                step={5}
+                value={speedPercent}
+                disabled={isCommittingSpeed}
+                onChange={(e) => setSpeedDraft(Number(e.target.value))}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter') e.currentTarget.blur()
+                }}
+                onBlur={(e) => {
+                  const raw = Number(e.currentTarget.value)
+                  const clamped = Number.isFinite(raw) ? Math.min(200, Math.max(10, raw)) : speedPercent
+                  commitSpeed(clamped)
+                }}
+                title="Type an exact speed percentage - applies on Enter or clicking away"
+                className="w-12 rounded bg-white/5 px-1 py-0.5 text-right normal-case text-gray-400 outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-50"
+              />
+              <span className="normal-case text-gray-400">%</span>
             </div>
             <input
               type="range"
@@ -711,7 +799,11 @@ export default function DetailSidebar({
               step={5}
               value={speedPercent}
               disabled={isCommittingSpeed}
-              onChange={(e) => setSpeedDraft(Number(e.target.value))}
+              onChange={(e) => {
+                const v = Number(e.target.value)
+                setSpeedDraft(v)
+                previewLive({ speed: v / 100 })
+              }}
               onMouseUp={(e) => commitSpeed(Number(e.currentTarget.value))}
               onTouchEnd={(e) => commitSpeed(Number(e.currentTarget.value))}
               onKeyUp={(e) => commitSpeed(Number(e.currentTarget.value))}
@@ -762,7 +854,11 @@ export default function DetailSidebar({
               max={128}
               value={volume}
               disabled={isCommittingVolume}
-              onChange={(e) => setVolumeDraft(Number(e.target.value))}
+              onChange={(e) => {
+                const v = Number(e.target.value)
+                setVolumeDraft(v)
+                previewLive({ volume: v })
+              }}
               onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
               onTouchEnd={(e) => commitVolume(Number(e.currentTarget.value))}
               onKeyUp={(e) => commitVolume(Number(e.currentTarget.value))}

+ 83 - 41
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect } from 'react'
-import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull, Film } from 'lucide-react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull, Film, Globe } from 'lucide-react'
 import type { LweStatus, LweInstallProgress, LinuxDistro } from '../../../../shared/types'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
@@ -16,6 +17,7 @@ export default function SettingsView() {
   const [defaultFps, setDefaultFps] = useState<string>('')
   const [fpsSaved, setFpsSaved] = useState(false)
   const [recommendedFpsEnabled, setRecommendedFpsEnabled] = useState(false)
+  const [recommendedWebFpsEnabled, setRecommendedWebFpsEnabled] = useState(false)
   const [resetFpsConfirm, setResetFpsConfirm] = useState(false)
   const [resetFpsMsg, setResetFpsMsg] = useState<string | null>(null)
 
@@ -25,10 +27,19 @@ export default function SettingsView() {
   const [lweCmakeArgs, setLweCmakeArgs] = useState('')
   const [defaultLweRepo, setDefaultLweRepo] = useState('')
   const [repoSaved, setRepoSaved] = useState(false)
-  const [lweInstalling, setLweInstalling] = useState(false)
-  const [lweProgress, setLweProgress] = useState<LweInstallProgress | null>(null)
+  const queryClient = useQueryClient()
+  // Lives in the query cache (written from App.tsx's always-mounted subscriber) instead of local
+  // state, so an in-progress build/install survives navigating away from Settings and back -
+  // this component unmounts on navigation, the query cache doesn't.
+  const { data: lweProgress = null } = useQuery<LweInstallProgress | null>({
+    queryKey: ['lwe-install-progress'],
+    queryFn: () => null,
+    staleTime: Infinity,
+    gcTime: Infinity
+  })
+  const lweInstalling = lweProgress !== null && !['done', 'error', 'installing-deps'].includes(lweProgress.stage)
+  const depsInstalling = lweProgress?.stage === 'installing-deps'
   const [distro, setDistro] = useState<LinuxDistro | null>(null)
-  const [depsInstalling, setDepsInstalling] = useState(false)
   const [uninstalling, setUninstalling] = useState(false)
   const [uninstallMsg, setUninstallMsg] = useState<string | null>(null)
   const [killingLwe, setKillingLwe] = useState(false)
@@ -53,6 +64,7 @@ export default function SettingsView() {
       setWorkshopPath(cfg.workshopPath ?? cfg.defaultWorkshopPath)
       setDefaultFps(cfg.defaultFps != null ? String(cfg.defaultFps) : '')
       setRecommendedFpsEnabled(cfg.recommendedFpsEnabled)
+      setRecommendedWebFpsEnabled(cfg.recommendedWebFpsEnabled)
       setLweRepoUrl(cfg.lweRepoUrl ?? '')
       setLweRepoBranch(cfg.lweRepoBranch ?? '')
       setLweCmakeArgs(cfg.lweCmakeArgs ?? '')
@@ -72,17 +84,15 @@ export default function SettingsView() {
     window.electronAPI.playlist.getAll().then((all) => setPlaylists(all.map((p) => ({ id: p.id, title: p.title }))))
   }, [])
 
+  // The event subscription itself lives in App.tsx (see lwe-install-progress query above) so it
+  // survives navigation; this just reacts to the shared progress reaching a terminal stage to
+  // refresh the installed-status display, which is fine to keep local since it's re-fetched on
+  // every mount anyway.
   useEffect(() => {
-    const unsub = window.electronAPI.on.lweInstallProgress((progress) => {
-      setLweProgress(progress)
-      if (progress.stage === 'done' || progress.stage === 'error') {
-        setLweInstalling(false)
-        setDepsInstalling(false)
-        window.electronAPI.lwe.status().then(setLweStatus)
-      }
-    })
-    return unsub
-  }, [])
+    if (lweProgress?.stage === 'done' || lweProgress?.stage === 'error') {
+      window.electronAPI.lwe.status().then(setLweStatus)
+    }
+  }, [lweProgress])
 
   async function handleBrowse() {
     const picked = await window.electronAPI.config.pickFolder()
@@ -112,8 +122,11 @@ export default function SettingsView() {
     }
   }
 
+  function setLweProgress(progress: LweInstallProgress) {
+    queryClient.setQueryData(['lwe-install-progress'], progress)
+  }
+
   async function handleInstallDeps() {
-    setDepsInstalling(true)
     setLweProgress({ stage: 'installing-deps', message: 'Starting dependency installation...', percentage: 0 })
     try {
       await window.electronAPI.lwe.installDeps()
@@ -123,7 +136,6 @@ export default function SettingsView() {
         message: `Dependency installation failed: ${(err as Error).message}`,
         percentage: 0
       })
-      setDepsInstalling(false)
     }
   }
 
@@ -135,7 +147,6 @@ export default function SettingsView() {
   }
 
   async function handleInstallLwe() {
-    setLweInstalling(true)
     setLweProgress({ stage: 'cloning', message: 'Starting installation...', percentage: 0 })
     try {
       // Persist the repo/cmake-args fields first so the build uses what's on screen
@@ -148,7 +159,6 @@ export default function SettingsView() {
         message: `Installation failed: ${(err as Error).message}`,
         percentage: 0
       })
-      setLweInstalling(false)
     }
   }
 
@@ -194,6 +204,12 @@ export default function SettingsView() {
     await window.electronAPI.config.setRecommendedFpsEnabled(newVal)
   }
 
+  async function handleRecommendedWebFpsToggle() {
+    const newVal = !recommendedWebFpsEnabled
+    setRecommendedWebFpsEnabled(newVal)
+    await window.electronAPI.config.setRecommendedWebFpsEnabled(newVal)
+  }
+
   async function handleResetFpsOverrides() {
     if (!resetFpsConfirm) { setResetFpsConfirm(true); return }
     setResetFpsConfirm(false)
@@ -412,6 +428,32 @@ export default function SettingsView() {
                 ffprobe (part of ffmpeg).
               </p>
             </div>
+            <div>
+              <label className="mt-1 flex items-center gap-3 cursor-pointer">
+                <button
+                  onClick={handleRecommendedWebFpsToggle}
+                  className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+                    recommendedWebFpsEnabled ? 'bg-indigo-600' : 'bg-white/10'
+                  }`}
+                >
+                  <span
+                    className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                      recommendedWebFpsEnabled ? 'translate-x-4' : 'translate-x-0.5'
+                    }`}
+                  />
+                </button>
+                <span className="flex items-center gap-2 text-sm text-gray-300">
+                  <Globe size={16} />
+                  Recommended web wallpaper settings
+                </span>
+              </label>
+              <p className="mt-2 text-xs text-gray-600">
+                Launches web (CEF-based) wallpapers at 60 FPS instead of the default limit.
+                The engine renders web content internally at 60 FPS regardless, so a lower
+                default just throttles how often that gets displayed, making HTML/Live2D
+                wallpapers feel choppy. Wallpapers with a manual FPS override are unaffected.
+              </p>
+            </div>
             <div>
               <label className="block text-xs text-gray-400 mb-1">Reset per-wallpaper FPS</label>
               <p className="text-xs text-gray-600 mb-2">
@@ -622,6 +664,29 @@ export default function SettingsView() {
             </div>
           )}
 
+          {lweProgress && (
+            <div className="mt-3 space-y-2">
+              <div className="flex items-center gap-2">
+                {lweProgress.stage === 'error' ? (
+                  <XCircle size={14} className="text-red-400 shrink-0" />
+                ) : lweProgress.stage === 'done' ? (
+                  <CheckCircle size={14} className="text-green-400 shrink-0" />
+                ) : (
+                  <Loader2 size={14} className="animate-spin text-indigo-400 shrink-0" />
+                )}
+                <p className="text-xs text-gray-300 whitespace-pre-wrap break-all">{lweProgress.message}</p>
+              </div>
+              {lweProgress.stage !== 'error' && lweProgress.stage !== 'done' && (
+                <div className="h-1.5 w-full rounded-full bg-white/5">
+                  <div
+                    className="h-full rounded-full bg-indigo-500 transition-all duration-300"
+                    style={{ width: `${lweProgress.percentage}%` }}
+                  />
+                </div>
+              )}
+            </div>
+          )}
+
           <div className="mt-4">
             <button
               onClick={handleKillAllLwe}
@@ -661,29 +726,6 @@ export default function SettingsView() {
               </span>
             </label>
           </div>
-
-          {lweProgress && (
-            <div className="mt-3 space-y-2">
-              <div className="flex items-center gap-2">
-                {lweProgress.stage === 'error' ? (
-                  <XCircle size={14} className="text-red-400 shrink-0" />
-                ) : lweProgress.stage === 'done' ? (
-                  <CheckCircle size={14} className="text-green-400 shrink-0" />
-                ) : (
-                  <Loader2 size={14} className="animate-spin text-indigo-400 shrink-0" />
-                )}
-                <p className="text-xs text-gray-300 whitespace-pre-wrap break-all">{lweProgress.message}</p>
-              </div>
-              {lweProgress.stage !== 'error' && lweProgress.stage !== 'done' && (
-                <div className="h-1.5 w-full rounded-full bg-white/5">
-                  <div
-                    className="h-full rounded-full bg-indigo-500 transition-all duration-300"
-                    style={{ width: `${lweProgress.percentage}%` }}
-                  />
-                </div>
-              )}
-            </div>
-          )}
         </div>
 
         <div>

+ 3 - 5
src/renderer/src/utils/async.ts

@@ -1,10 +1,8 @@
+// Runs concurrently so one slow/stuck item (e.g. a wallpaper folder on an unresponsive mount)
+// can't stall the rest of the batch behind it.
 export async function forEachIgnoringErrors<T>(
   items: T[],
   fn: (item: T) => Promise<unknown>
 ): Promise<void> {
-  for (const item of items) {
-    try {
-      await fn(item)
-    } catch {}
-  }
+  await Promise.allSettled(items.map((item) => fn(item)))
 }

+ 5 - 0
src/shared/constants.ts

@@ -1,3 +1,8 @@
 export const WE_APP_ID = 431960
 
 export const DEFAULT_LWE_REPO = 'https://github.com/Almamu/linux-wallpaperengine.git'
+
+// CEF's own windowless_frame_rate is set to max(60, maximumFPS) regardless of --fps (see
+// CWeb.cpp), so it's already trying to paint at least this fast internally - a lower --fps just
+// throttles how often the engine's outer render loop actually picks up and displays a frame.
+export const RECOMMENDED_WEB_FPS = 60

+ 1 - 0
src/shared/ipc-channels.ts

@@ -52,6 +52,7 @@ export const IpcChannels = {
   CONFIG_SET_WORKSHOP_PATH: 'config:set-workshop-path',
   CONFIG_SET_DEFAULT_FPS: 'config:set-default-fps',
   CONFIG_SET_RECOMMENDED_FPS: 'config:set-recommended-fps',
+  CONFIG_SET_RECOMMENDED_WEB_FPS: 'config:set-recommended-web-fps',
   CONFIG_SET_LWE_REPO: 'config:set-lwe-repo',
   CONFIG_SET_LWE_CMAKE_ARGS: 'config:set-lwe-cmake-args',
   CONFIG_SET_BACKUP_PATH: 'config:set-backup-path',