Преглед на файлове

added a lot of new functionality

UwU преди 1 седмица
родител
ревизия
ed9d9611cd
променени са 28 файла, в които са добавени 1740 реда и са изтрити 142 реда
  1. 1 1
      package.json
  2. 113 0
      patches/steamworks-vote/README.md
  3. 70 0
      patches/steamworks-vote/build.sh
  4. 33 0
      patches/steamworks-vote/steamworks-js-client-d-ts.patch
  5. 69 0
      patches/steamworks-vote/steamworks-js-workshop.patch
  6. 81 0
      patches/steamworks-vote/steamworks-rs-ugc.patch
  7. 3 1
      src/main/ipc/backup.handlers.ts
  8. 8 0
      src/main/ipc/config.handlers.ts
  9. 12 1
      src/main/ipc/lwe.handlers.ts
  10. 14 5
      src/main/ipc/steam.handlers.ts
  11. 52 0
      src/main/services/backup.service.ts
  12. 11 0
      src/main/services/config.service.ts
  13. 78 0
      src/main/services/dependency.service.ts
  14. 14 0
      src/main/services/library.service.ts
  15. 136 3
      src/main/services/lwe.service.ts
  16. 1 1
      src/main/services/playlist-player.service.ts
  17. 317 50
      src/main/services/steam.service.ts
  18. 15 2
      src/main/services/wallpaper.service.ts
  19. 10 2
      src/preload/index.ts
  20. 332 33
      src/renderer/src/components/common/DetailSidebar.tsx
  21. 101 8
      src/renderer/src/components/library/LibraryView.tsx
  22. 25 3
      src/renderer/src/components/library/WallpaperCard.tsx
  23. 14 6
      src/renderer/src/components/playlist/PlaylistsView.tsx
  24. 40 1
      src/renderer/src/components/settings/SettingsView.tsx
  25. 155 21
      src/renderer/src/components/workshop/WorkshopBrowser.tsx
  26. 9 3
      src/renderer/src/components/workshop/WorkshopCard.tsx
  27. 2 0
      src/shared/ipc-channels.ts
  28. 24 1
      src/shared/types.ts

+ 1 - 1
package.json

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

+ 113 - 0
patches/steamworks-vote/README.md

@@ -0,0 +1,113 @@
+# steamworks.js vote support patch
+
+`steamworks.js` (the Node wrapper this app uses for all Steam integration) doesn't bind
+`ISteamUGC::SetUserItemVote`/`GetUserItemVote` at all - confirmed by reading its actual source and
+the `steamworks-rs` Rust crate underneath it, not just its published `.d.ts`. Without this patch,
+`we_manager` casts votes via a raw FFI call to the flat `SetUserItemVote` C function directly
+(see `voteOnItem()` in `src/main/services/steam.service.ts`), which works for casting the vote but
+can't reliably read back whether Steam actually accepted it: `steamworks.js`'s native addon owns
+Steam's manual-dispatch loop for this process and consumes every completed call's result as it
+ticks (~30/sec), including ones made via raw FFI from outside it - so there's no way to get real
+confirmation without going through `steamworks.js`'s own dispatch ownership.
+
+These patches add `voteItem`/`getUserVote` to a `steamworks.js` build the same way its own
+`subscribe`/`unsubscribe`/`getUserItems` are already implemented - registered through its existing
+internal async-call tracking (`register_call_result`), so they resolve correctly with no race.
+Once built and in place, `we_manager` detects and uses them automatically (see
+`getPatchedVoteApi()` in `steam.service.ts`) - the app keeps working without this patch too, just
+falling back to "trust the vote, sync later" instead of genuine confirmation.
+
+## What's in this directory
+
+- `steamworks-rs-ugc.patch` - adds `UGC::vote_up_item`/`UGC::user_item_vote` to the `steamworks`
+  Rust crate (`Noxime/steamworks-rs`), mirroring its existing `subscribe_item`/`unsubscribe_item`
+  exactly.
+- `steamworks-js-workshop.patch` - adds the corresponding `voteItem`/`getUserVote` napi-exposed
+  async functions to `steamworks.js`'s own `src/api/workshop.rs`, mirroring its existing
+  `subscribe`/`unsubscribe`.
+- `steamworks-js-client-d-ts.patch` - adds the matching TypeScript declarations to `client.d.ts`.
+  Not strictly required to apply by hand: `npm run build` regenerates this file from the Rust
+  `#[napi]` annotations automatically. Kept here for reference/as a fallback.
+
+All three were generated against and verified (`git apply --check`) to apply cleanly to:
+- `ceifa/steamworks.js` at its current `main` (the version this project's `package.json` pins,
+  `0.4.0`, predates these files slightly - a couple of unrelated functions were added upstream
+  since - but the patch targets are unaffected).
+- `Noxime/steamworks-rs` at the exact commit `steamworks.js`'s own `Cargo.toml` pins
+  (`fbb79635b06b4feea8261e5ca3e8ea3ef42facf9`).
+
+**Not yet compiled or run** - this sandbox has no Rust toolchain, so the Rust code was written and
+verified by hand against the real upstream source (exact struct layouts/callback ids
+cross-checked against actual `bindgen`-generated output, not guessed) but never built. Do a real
+build + a real vote/like click before trusting it fully.
+
+## Building it (on your real machine, which has Steam installed and running - required by
+## steamworks.js's own build)
+
+```bash
+# 1. Clone both repos somewhere, e.g. alongside your other private forks
+cd ~/projects/private
+git clone https://github.com/Noxime/steamworks-rs
+git clone https://github.com/ceifa/steamworks.js steamworksjs
+cd steamworks-rs && git checkout fbb79635b06b4feea8261e5ca3e8ea3ef42facf9 && cd ..
+
+# 2. Apply the patches
+cd steamworks-rs && git apply /path/to/we_manager/patches/steamworks-vote/steamworks-rs-ugc.patch && cd ..
+cd steamworksjs && git apply /path/to/we_manager/patches/steamworks-vote/steamworks-js-workshop.patch && cd ..
+
+# 3. Point steamworks.js's build at your patched local steamworks-rs checkout instead of the
+#    pinned git rev - add this to the END of steamworksjs/Cargo.toml:
+cat >> steamworksjs/Cargo.toml <<'EOF'
+
+[patch."https://github.com/Noxime/steamworks-rs/"]
+steamworks = { path = "../steamworks-rs" }
+EOF
+
+# 4. Make sure you have: latest Node.js, Rust (rustup.rs), and Clang (needed by bindgen).
+#    On Fedora/Nobara: sudo dnf install clang clang-devel
+sudo dnf install clang clang-devel   # if not already installed
+
+cd steamworksjs
+npm install
+npm run build:debug   # or `npm run build` for a release build - both work, release is smaller/faster
+```
+
+If it builds cleanly, `steamworksjs/dist/linux64/steamworksjs.linux-x64-gnu.node` and
+`steamworksjs/client.d.ts` are the freshly built, patched artifacts.
+
+## Wiring it into we_manager
+
+Easiest: point `we_manager`'s own `package.json` dependency at your local patched checkout instead
+of the npm-published package, so it survives future `npm install`s:
+
+```jsonc
+// we_manager/package.json
+"dependencies": {
+  "steamworks.js": "file:../steamworksjs"
+}
+```
+
+Then from `we_manager`:
+
+```bash
+npm install
+npm run build
+```
+
+(A quicker, non-persistent way to try it first: just copy the freshly built
+`steamworksjs/dist/linux64/steamworksjs.linux-x64-gnu.node` over
+`we_manager/node_modules/steamworks.js/dist/linux64/steamworksjs.linux-x64-gnu.node` directly -
+gets wiped out by the next `npm install` though, so switch to the `file:` dependency once you've
+confirmed it works.)
+
+## Verifying it actually took effect
+
+`getPatchedVoteApi()` in `steam.service.ts` detects the new functions at runtime - no config flag
+needed. To confirm: like something in the app, then check the main process console log. There's no
+explicit "patched build detected" log line today, but you can add one temporarily, or just trust
+the behavior - once this is wired up, `voteOnItemAndConfirm()`'s patched branch runs, meaning a
+returned `confirmed: true` is now a real answer from Steam, not an optimistic guess, and
+`checkUserVote(itemId)` becomes available for a genuine on-demand "is this item already liked"
+check (not yet wired into any UI - a good next step, since once this works it can replace the
+whole crawl-based `votedUpCache`/`getVotedUpItemIds()` mechanism entirely for populating "liked"
+state, not just for confirming a fresh vote).

+ 70 - 0
patches/steamworks-vote/build.sh

@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Idempotent build script for the steamworks.js vote-support patch. Safe to re-run: every step
+# checks whether it already happened before doing anything.
+#
+# Usage: ./build.sh [path-to-projects-dir]
+# Defaults to ~/projects/private if no argument is given (matches this project's usual layout).
+set -euo pipefail
+
+PROJECTS_DIR="${1:-$HOME/projects/private}"
+PATCHES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+STEAMWORKS_RS="$PROJECTS_DIR/steamworks-rs"
+STEAMWORKSJS="$PROJECTS_DIR/steamworksjs"
+PINNED_REV="fbb79635b06b4feea8261e5ca3e8ea3ef42facf9"
+
+echo "==> Rust toolchain"
+if ! command -v cargo >/dev/null 2>&1; then
+  curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+fi
+# shellcheck disable=SC1091
+source "$HOME/.cargo/env"
+cargo --version
+
+echo "==> Cloning steamworks-rs (skipped if already present)"
+if [ ! -d "$STEAMWORKS_RS" ]; then
+  git clone https://github.com/Noxime/steamworks-rs "$STEAMWORKS_RS"
+fi
+git -C "$STEAMWORKS_RS" checkout --quiet "$PINNED_REV"
+
+echo "==> Cloning steamworks.js (skipped if already present)"
+if [ ! -d "$STEAMWORKSJS" ]; then
+  git clone https://github.com/ceifa/steamworks.js "$STEAMWORKSJS"
+fi
+
+echo "==> Applying steamworks-rs patch (skipped if already applied)"
+if grep -q "vote_up_item" "$STEAMWORKS_RS/src/ugc.rs"; then
+  echo "    already applied"
+else
+  git -C "$STEAMWORKS_RS" apply "$PATCHES_DIR/steamworks-rs-ugc.patch"
+fi
+
+echo "==> Applying steamworks.js patch (skipped if already applied)"
+if grep -q "vote_item" "$STEAMWORKSJS/src/api/workshop.rs"; then
+  echo "    already applied"
+else
+  git -C "$STEAMWORKSJS" apply "$PATCHES_DIR/steamworks-js-workshop.patch"
+fi
+
+echo "==> Pointing steamworks.js's Cargo.toml at the local patched steamworks-rs (skipped if already done)"
+if grep -qF 'Noxime/steamworks-rs' "$STEAMWORKSJS/Cargo.toml" && grep -q '\[patch\.' "$STEAMWORKSJS/Cargo.toml"; then
+  echo "    already present"
+else
+  {
+    printf '\n[patch."https://github.com/Noxime/steamworks-rs/"]\n'
+    printf 'steamworks = { path = "../steamworks-rs" }\n'
+  } >> "$STEAMWORKSJS/Cargo.toml"
+fi
+
+echo "==> Building steamworks.js (this runs cargo + napi, may take a minute)"
+cd "$STEAMWORKSJS"
+npm install
+npm run build:debug
+
+echo ""
+echo "==> Done. Built artifact:"
+ls -la "$STEAMWORKSJS/dist/linux64/steamworksjs.linux-x64-gnu.node"
+echo ""
+echo "Next: wire it into we_manager with:"
+echo "  cd /path/to/we_manager"
+echo "  npm pkg set dependencies.steamworks.js=\"file:$STEAMWORKSJS\""
+echo "  npm install && npm run build"

+ 33 - 0
patches/steamworks-vote/steamworks-js-client-d-ts.patch

@@ -0,0 +1,33 @@
+--- a/client.d.ts	2026-09-18 16:42:37.407700450 +0000
++++ b/client.d.ts	2026-09-18 16:42:37.407700450 +0000
+@@ -268,6 +268,11 @@
+     current: bigint
+     total: bigint
+   }
++  export interface UserItemVote {
++    votedUp: boolean
++    votedDown: boolean
++    voteSkipped: boolean
++  }
+   export const enum UpdateStatus {
+     Invalid = 0,
+     PreparingConfig = 1,
+@@ -297,6 +302,18 @@
+    */
+   export function unsubscribe(itemId: bigint): Promise<void>
+   /**
++   * Sets the current user's vote (up or down) on a workshop item.
++   *
++   * {@link https://partner.steamgames.com/doc/api/ISteamUGC#SetUserItemVote}
++   */
++  export function voteItem(itemId: bigint, voteUp: boolean): Promise<void>
++  /**
++   * Gets the current user's existing vote (if any) on a workshop item.
++   *
++   * {@link https://partner.steamgames.com/doc/api/ISteamUGC#GetUserItemVote}
++   */
++  export function getUserVote(itemId: bigint): Promise<UserItemVote>
++  /**
+    * Gets the current state of a workshop item on this client. States can be combined.
+    *
+    * @returns a number with the current item state, e.g. 9

+ 69 - 0
patches/steamworks-vote/steamworks-js-workshop.patch

@@ -0,0 +1,69 @@
+--- a/src/api/workshop.rs	2026-09-18 16:42:37.406700437 +0000
++++ b/src/api/workshop.rs	2026-09-18 16:42:37.406700437 +0000
+@@ -108,6 +108,13 @@
+         pub total: BigInt,
+     }
+ 
++    #[napi(object)]
++    pub struct UserItemVote {
++        pub voted_up: bool,
++        pub voted_down: bool,
++        pub vote_skipped: bool,
++    }
++
+     #[derive(Debug)]
+     #[napi]
+     pub enum UpdateStatus {
+@@ -319,6 +326,52 @@
+             Err(e) => Err(Error::from_reason(e.to_string())),
+         }
+     }
++
++    /// Sets the current user's vote (up or down) on a workshop item.
++    ///
++    /// {@link https://partner.steamgames.com/doc/api/ISteamUGC#SetUserItemVote}
++    #[napi]
++    pub async fn vote_item(item_id: BigInt, vote_up: bool) -> Result<(), Error> {
++        let client = crate::client::get_client();
++        let (tx, rx) = oneshot::channel();
++
++        client
++            .ugc()
++            .vote_up_item(PublishedFileId(item_id.get_u64().1), vote_up, |result| {
++                tx.send(result).unwrap();
++            });
++
++        let result = rx.await.unwrap();
++        match result {
++            Ok(()) => Ok(()),
++            Err(e) => Err(Error::from_reason(e.to_string())),
++        }
++    }
++
++    /// Gets the current user's existing vote (if any) on a workshop item.
++    ///
++    /// {@link https://partner.steamgames.com/doc/api/ISteamUGC#GetUserItemVote}
++    #[napi]
++    pub async fn get_user_vote(item_id: BigInt) -> Result<UserItemVote, Error> {
++        let client = crate::client::get_client();
++        let (tx, rx) = oneshot::channel();
++
++        client
++            .ugc()
++            .user_item_vote(PublishedFileId(item_id.get_u64().1), |result| {
++                tx.send(result).unwrap();
++            });
++
++        let result = rx.await.unwrap();
++        match result {
++            Ok(v) => Ok(UserItemVote {
++                voted_up: v.voted_up,
++                voted_down: v.voted_down,
++                vote_skipped: v.vote_skipped,
++            }),
++            Err(e) => Err(Error::from_reason(e.to_string())),
++        }
++    }
+ 
+     /// Gets the current state of a workshop item on this client. States can be combined.
+     ///

+ 81 - 0
patches/steamworks-vote/steamworks-rs-ugc.patch

@@ -0,0 +1,81 @@
+--- a/src/ugc.rs	2026-09-18 16:42:37.405700424 +0000
++++ b/src/ugc.rs	2026-09-18 16:42:37.406700437 +0000
+@@ -517,6 +517,14 @@
+     pub timestamp: u32,
+ }
+ 
++#[derive(Clone, Copy, Debug)]
++#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
++pub struct UserItemVote {
++    pub voted_up: bool,
++    pub voted_down: bool,
++    pub vote_skipped: bool,
++}
++
+ impl<Manager> UGC<Manager> {
+     /// Suspends or resumes all workshop downloads
+     pub fn suspend_downloads(&self, suspend: bool) {
+@@ -614,6 +622,63 @@
+                     })
+                 },
+             );
++        }
++    }
++
++    /// Sets the current user's vote (up or down) on a workshop item.
++    ///
++    /// {@link https://partner.steamgames.com/doc/api/ISteamUGC#SetUserItemVote}
++    pub fn vote_up_item<F>(&self, published_file_id: PublishedFileId, vote_up: bool, cb: F)
++    where
++        F: FnOnce(Result<(), SteamError>) + 'static + Send,
++    {
++        unsafe {
++            let api_call =
++                sys::SteamAPI_ISteamUGC_SetUserItemVote(self.ugc, published_file_id.0, vote_up);
++            register_call_result::<sys::SetUserItemVoteResult_t, _, _>(
++                &self.inner,
++                api_call,
++                CALLBACK_BASE_ID + 8,
++                move |v, io_error| {
++                    cb(if io_error {
++                        Err(SteamError::IOFailure)
++                    } else if v.m_eResult != sys::EResult::k_EResultOK {
++                        Err(v.m_eResult.into())
++                    } else {
++                        Ok(())
++                    })
++                },
++            );
++        }
++    }
++
++    /// Gets the current user's existing vote (if any) on a workshop item.
++    ///
++    /// {@link https://partner.steamgames.com/doc/api/ISteamUGC#GetUserItemVote}
++    pub fn user_item_vote<F>(&self, published_file_id: PublishedFileId, cb: F)
++    where
++        F: FnOnce(Result<UserItemVote, SteamError>) + 'static + Send,
++    {
++        unsafe {
++            let api_call = sys::SteamAPI_ISteamUGC_GetUserItemVote(self.ugc, published_file_id.0);
++            register_call_result::<sys::GetUserItemVoteResult_t, _, _>(
++                &self.inner,
++                api_call,
++                CALLBACK_BASE_ID + 9,
++                move |v, io_error| {
++                    cb(if io_error {
++                        Err(SteamError::IOFailure)
++                    } else if v.m_eResult != sys::EResult::k_EResultOK {
++                        Err(v.m_eResult.into())
++                    } else {
++                        Ok(UserItemVote {
++                            voted_up: v.m_bVotedUp,
++                            voted_down: v.m_bVotedDown,
++                            vote_skipped: v.m_bVoteSkipped,
++                        })
++                    })
++                },
++            );
+         }
+     }
+ 

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

@@ -24,7 +24,9 @@ async function backupOne(id: string, win: BrowserWindow): Promise<{ ok: boolean;
     library.updateWallpaper(id, {
       source: 'backup',
       localPath: backup.getBackupDir(id),
-      subscribed: false
+      subscribed: false,
+      downloading: false,
+      downloadFailed: false
     })
     unsubscribed = true
   }

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

@@ -40,6 +40,8 @@ import {
   setDefaultAudioSensitivity,
   getDisablePuppetAnimation,
   setDisablePuppetAnimation,
+  getDisableAnimations,
+  setDisableAnimations,
   getSteamIdentity,
   setSteamIdentity,
   getIgnoredCreators,
@@ -78,6 +80,7 @@ export function registerConfigHandlers(): void {
     ambientVolume: getAmbientVolume(),
     defaultAudioSensitivity: getDefaultAudioSensitivity(),
     disablePuppetAnimation: getDisablePuppetAnimation(),
+    disableAnimations: getDisableAnimations(),
     steamIdentity: getSteamIdentity(),
     ignoredCreators: getIgnoredCreators()
   }))
@@ -170,6 +173,11 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.CONFIG_SET_DISABLE_ANIMATIONS, (_e, disabled: boolean) => {
+    setDisableAnimations(disabled)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_LWE_REPO, (_e, url: string | null, branch: string | null) => {
     setLweRepo(url, branch)
     return { ok: true }

+ 12 - 1
src/main/ipc/lwe.handlers.ts

@@ -1,6 +1,6 @@
 import { ipcMain, type BrowserWindow } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, stopLwe, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties, listLweAudioObjects } from '../services/lwe.service'
+import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, stopLwe, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties, listLweAudioObjects, listLweEffects } from '../services/lwe.service'
 import { invalidateEnvCache } from '../services/wallpaper.service'
 import { getConnectedScreens } from '../utils/platform'
 
@@ -65,6 +65,8 @@ export function registerLweHandlers(win: BrowserWindow): void {
         xray?: boolean
         scaling?: string
         zoom?: number
+        offsetX?: number
+        offsetY?: number
         disableParallax?: boolean
         cornerColor?: string
         speed?: number
@@ -104,4 +106,13 @@ export function registerLweHandlers(win: BrowserWindow): void {
       return []
     }
   })
+
+  ipcMain.handle(IpcChannels.LWE_LIST_EFFECTS, async (_e, wallpaperPath: string) => {
+    try {
+      return await listLweEffects(wallpaperPath)
+    } catch (err) {
+      console.warn('[LWE] list-effects failed for', wallpaperPath, ':', (err as Error).message)
+      return []
+    }
+  })
 }

+ 14 - 5
src/main/ipc/steam.handlers.ts

@@ -4,6 +4,7 @@ import { IpcChannels } from '@shared/ipc-channels'
 import * as steam from '../services/steam.service'
 import * as library from '../services/library.service'
 import * as backup from '../services/backup.service'
+import { setDependencyInstaller } from '../services/dependency.service'
 
 function startDownloadPoll(win: BrowserWindow, itemId: string): void {
   const idBig = BigInt(itemId)
@@ -35,6 +36,12 @@ function startDownloadPoll(win: BrowserWindow, itemId: string): void {
 }
 
 export function registerSteamHandlers(win: BrowserWindow): void {
+  setDependencyInstaller(async (itemId) => {
+    await steam.subscribeToItem(BigInt(itemId))
+    steam.downloadItem(BigInt(itemId))
+    startDownloadPoll(win, itemId)
+  })
+
   ipcMain.handle(IpcChannels.STEAM_IS_RUNNING, () => {
     return steam.isSteamRunning()
   })
@@ -67,10 +74,12 @@ export function registerSteamHandlers(win: BrowserWindow): void {
         library.updateWallpaper(itemId, {
           source: 'backup',
           localPath: backup.getBackupDir(itemId),
-          subscribed: false
+          subscribed: false,
+          downloading: false,
+          downloadFailed: false
         })
       } catch (err) {
-        library.updateWallpaper(itemId, { subscribed: false })
+        library.updateWallpaper(itemId, { subscribed: false, downloading: false, downloadFailed: false })
         console.error('[Steam] Failed to resolve backup dir on unsubscribe:', err)
       }
     } else {
@@ -93,9 +102,9 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     return { ok: true }
   })
 
-  ipcMain.handle(IpcChannels.STEAM_VOTE, (_e, itemId: string, voteUp: boolean) => {
-    steam.voteOnItem(BigInt(itemId), voteUp)
-    return { ok: true }
+  ipcMain.handle(IpcChannels.STEAM_VOTE, async (_e, itemId: string, voteUp: boolean) => {
+    const confirmed = await steam.voteOnItemAndConfirm(BigInt(itemId), voteUp)
+    return { ok: true, confirmed }
   })
 
   ipcMain.handle(IpcChannels.STEAM_OPEN_WORKSHOP, (_e, itemId: string) => {

+ 52 - 0
src/main/services/backup.service.ts

@@ -1,4 +1,5 @@
 import { BrowserWindow } from 'electron'
+import * as crypto from 'crypto'
 import * as fs from 'fs'
 import * as path from 'path'
 import { IpcChannels } from '@shared/ipc-channels'
@@ -21,6 +22,29 @@ export function isBackedUp(id: string): boolean {
   }
 }
 
+function hashFile(filePath: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const hash = crypto.createHash('sha256')
+    const stream = fs.createReadStream(filePath)
+    stream.on('error', reject)
+    stream.on('data', (chunk) => hash.update(chunk))
+    stream.on('end', () => resolve(hash.digest('hex')))
+  })
+}
+
+async function verifyCopy(srcFile: string, destFile: string, expectedSize: number): Promise<void> {
+  const destSize = (await fs.promises.stat(destFile)).size
+  if (destSize !== expectedSize) {
+    throw new Error(
+      `Backup verification failed for ${path.basename(destFile)}: expected ${expectedSize} bytes, got ${destSize}`
+    )
+  }
+  const [srcHash, destHash] = await Promise.all([hashFile(srcFile), hashFile(destFile)])
+  if (srcHash !== destHash) {
+    throw new Error(`Backup verification failed for ${path.basename(destFile)}: hash mismatch`)
+  }
+}
+
 function walkFiles(dirPath: string, baseDir = dirPath): { relPath: string; size: number }[] {
   const results: { relPath: string; size: number }[] = []
   for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
@@ -51,8 +75,31 @@ export async function backupWallpaper(
     for (const file of files) {
       const srcFile = path.join(sourcePath, file.relPath)
       const destFile = path.join(destDir, file.relPath)
+
+      let srcStat: fs.Stats
+      try {
+        srcStat = await fs.promises.stat(srcFile)
+      } catch {
+        throw new Error(`Source file is gone, refusing to back up: ${file.relPath}`)
+      }
+      if (!srcStat.isFile() || srcStat.size !== file.size) {
+        throw new Error(`Source file changed since it was scanned, refusing to back up: ${file.relPath}`)
+      }
+
       fs.mkdirSync(path.dirname(destFile), { recursive: true })
       await fs.promises.copyFile(srcFile, destFile)
+
+      if (!win.isDestroyed()) {
+        win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
+          itemId: id,
+          bytesCopied,
+          bytesTotal,
+          percentage: bytesTotal > 0 ? Math.round((bytesCopied / bytesTotal) * 100) : 0,
+          status: 'verifying'
+        })
+      }
+
+      await verifyCopy(srcFile, destFile, file.size)
       bytesCopied += file.size
 
       if (!win.isDestroyed()) {
@@ -66,6 +113,11 @@ export async function backupWallpaper(
       }
     }
   } catch (err) {
+    try {
+      await fs.promises.rm(destDir, { recursive: true, force: true })
+    } catch {
+      /* ignore */
+    }
     if (!win.isDestroyed()) {
       win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
         itemId: id,

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

@@ -26,6 +26,8 @@ interface AppConfig {
   defaultAudioSensitivity: number
   /** Passes --render-debug no-puppet-animation to LWE - freezes puppet (.mdl) meshes at their bind pose, for troubleshooting */
   disablePuppetAnimation: boolean
+  /** Passes --disable-animations to LWE - freezes all scene time (scripts, particles, effects and puppet meshes) */
+  disableAnimations: boolean
   steamIdentity: SteamIdentity
   /** steamId64 of creators whose workshop items are hidden from browsing by default */
   ignoredCreators: string[]
@@ -52,6 +54,7 @@ const store = new Store<AppConfig>({
     ambientVolume: null,
     defaultAudioSensitivity: 1,
     disablePuppetAnimation: false,
+    disableAnimations: false,
     steamIdentity: 'wallpaper-engine',
     ignoredCreators: []
   }
@@ -206,6 +209,14 @@ export function setDisablePuppetAnimation(disabled: boolean): void {
   store.set('disablePuppetAnimation', disabled)
 }
 
+export function getDisableAnimations(): boolean {
+  return store.get('disableAnimations')
+}
+
+export function setDisableAnimations(disabled: boolean): void {
+  store.set('disableAnimations', disabled)
+}
+
 /** Default --audio-sensitivity multiplier for audio-reactive objects with no per-wallpaper override. */
 export function getDefaultAudioSensitivity(): number {
   return store.get('defaultAudioSensitivity')

+ 78 - 0
src/main/services/dependency.service.ts

@@ -0,0 +1,78 @@
+import { BrowserWindow, dialog } from 'electron'
+import * as fs from 'fs'
+import * as path from 'path'
+import type { WallpaperMeta } from '@shared/types'
+
+type DependencyInstaller = (dependencyId: string) => Promise<void>
+
+let installer: DependencyInstaller | null = null
+
+const alreadyAsked = new Set<string>()
+
+export function setDependencyInstaller(fn: DependencyInstaller): void {
+  installer = fn
+}
+
+export function getMissingDependency(wallpaper: WallpaperMeta): string | null {
+  if (!wallpaper.localPath) return null
+
+  try {
+    const project = JSON.parse(fs.readFileSync(path.join(wallpaper.localPath, 'project.json'), 'utf8'))
+    const dependency = project?.dependency
+    if (dependency === undefined || dependency === null || dependency === '') return null
+
+    const dependencyId = String(dependency)
+    if (!/^\d+$/.test(dependencyId)) return null
+
+    const baseDir = path.join(path.dirname(path.resolve(wallpaper.localPath)), dependencyId)
+    return fs.existsSync(path.join(baseDir, 'project.json')) ? null : dependencyId
+  } catch {
+    return null
+  }
+}
+
+// throws unless the wallpaper can go ahead, since after "Install now" the download has only just started
+export async function ensureDependencyInstalled(
+  wallpaper: WallpaperMeta,
+  mode: 'always' | 'once' = 'always'
+): Promise<void> {
+  const dependencyId = getMissingDependency(wallpaper)
+  if (!dependencyId) return
+
+  const title = wallpaper.title || wallpaper.id
+
+  if (mode === 'once' && alreadyAsked.has(dependencyId)) {
+    throw new Error(`"${title}" was skipped: it requires Workshop item ${dependencyId}`)
+  }
+  alreadyAsked.add(dependencyId)
+
+  const options = {
+    type: 'warning' as const,
+    title: 'Missing required wallpaper',
+    message: `"${title}" needs another Workshop item that is not installed`,
+    detail:
+      `Workshop item ${dependencyId} is the base this wallpaper is built on. Without it the wallpaper ` +
+      `cannot load.\n\nSubscribe to it and download it now?`,
+    buttons: ['Install now', 'Apply anyway', 'Cancel'],
+    defaultId: 0,
+    cancelId: 2
+  }
+  const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
+  const { response } = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options)
+
+  if (response === 1) return
+
+  if (response === 0) {
+    if (!installer) throw new Error(`Cannot install required item ${dependencyId}: Steam integration is not ready`)
+    try {
+      await installer(dependencyId)
+    } catch (err) {
+      throw new Error(
+        `Could not subscribe to required item ${dependencyId} (is Steam running?): ${(err as Error).message}`
+      )
+    }
+    throw new Error(`Downloading required item ${dependencyId} - apply "${title}" again once it has finished`)
+  }
+
+  throw new Error(`"${title}" was not applied: it requires Workshop item ${dependencyId}`)
+}

+ 14 - 0
src/main/services/library.service.ts

@@ -37,6 +37,20 @@ const store = new Store<LibraryStore>({
 
 export function initLibrary(): void {
   console.log('[Library] Store initialized at', store.path)
+  repairStaleDownloadFlags()
+}
+
+function repairStaleDownloadFlags(): void {
+  const wallpapers = store.get('wallpapers')
+  let changed = false
+  for (const wallpaper of Object.values(wallpapers)) {
+    if (!wallpaper.subscribed && (wallpaper.downloading || wallpaper.downloadFailed)) {
+      wallpaper.downloading = false
+      wallpaper.downloadFailed = false
+      changed = true
+    }
+  }
+  if (changed) store.set('wallpapers', wallpapers)
 }
 
 export function getAllWallpapers(filters?: LibraryFilters): WallpaperMeta[] {

+ 136 - 3
src/main/services/lwe.service.ts

@@ -11,7 +11,8 @@ import type {
   LinuxDistro,
   LweSceneObject,
   LweProperty,
-  LweAudioObject
+  LweAudioObject,
+  LweSceneEffect
 } from '@shared/types'
 import {
   isCommandAvailable,
@@ -29,7 +30,8 @@ import {
   getAudioScreen,
   getAmbientVolume,
   getDefaultAudioSensitivity,
-  getDisablePuppetAnimation
+  getDisablePuppetAnimation,
+  getDisableAnimations
 } from './config.service'
 import { DEFAULT_LWE_REPO } from '@shared/constants'
 
@@ -418,6 +420,8 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
     invalidateObjectFlagsSupport()
     invalidateAudioSensitivitySupport()
     invalidateSoundVolumeSupport()
+    invalidateEffectFlagsSupport()
+    invalidateDisableAnimationsSupport()
 
     const status = getLweStatus()
     if (status.installed) {
@@ -533,6 +537,8 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
     invalidateObjectFlagsSupport()
     invalidateAudioSensitivitySupport()
     invalidateSoundVolumeSupport()
+    invalidateEffectFlagsSupport()
+    invalidateDisableAnimationsSupport()
 
     return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
   } catch (err) {
@@ -706,6 +712,62 @@ function supportsSoundVolume(): boolean {
   return soundVolumeSupported
 }
 
+let effectFlagsSupported: boolean | null = null
+
+function invalidateEffectFlagsSupport(): void {
+  effectFlagsSupported = null
+}
+
+function supportsEffectFlags(): boolean {
+  if (effectFlagsSupported !== null) return effectFlagsSupported
+  try {
+    const binaryPath = getLweBinaryPath()
+    const envVars = buildLweEnvVars()
+    const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
+      timeout: 5_000,
+      encoding: 'utf8',
+      maxBuffer: 2 * 1024 * 1024
+    })
+    effectFlagsSupported = stdout.includes('--list-effects')
+  } catch {
+    effectFlagsSupported = false
+  }
+  return effectFlagsSupported
+}
+
+let disableAnimationsSupported: boolean | null = null
+
+function invalidateDisableAnimationsSupport(): void {
+  disableAnimationsSupported = null
+}
+
+function supportsDisableAnimations(): boolean {
+  if (disableAnimationsSupported !== null) return disableAnimationsSupported
+  try {
+    const binaryPath = getLweBinaryPath()
+    const envVars = buildLweEnvVars()
+    const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
+      timeout: 5_000,
+      encoding: 'utf8',
+      maxBuffer: 2 * 1024 * 1024
+    })
+    disableAnimationsSupported = stdout.includes('--disable-animations')
+  } catch {
+    disableAnimationsSupported = false
+  }
+  return disableAnimationsSupported
+}
+
+export function parseCustomArgs(raw: string): string[] {
+  const tokens: string[] = []
+  const re = /"([^"]*)"|'([^']*)'|(\S+)/g
+  let match: RegExpExecArray | null
+  while ((match = re.exec(raw)) !== null) {
+    tokens.push(match[1] ?? match[2] ?? match[3])
+  }
+  return tokens
+}
+
 /** Build the common LWE args (assets dir, screen roots, fps, volume, object overrides). */
 function buildLweArgs(
   wallpaperPath: string,
@@ -715,14 +777,19 @@ function buildLweArgs(
     volume?: number
     disabledObjects?: string[]
     enabledObjects?: string[]
+    disabledEffects?: string[]
+    enabledEffects?: string[]
     propertyOverrides?: Record<string, string>
     scalingMode?: string
     zoom?: number
+    offsetX?: number
+    offsetY?: number
     disableParallax?: boolean
     cornerColor?: string
     speed?: number
     audioSensitivity?: Record<string, number>
     soundVolume?: Record<string, number>
+    customArgs?: string
   }
 ): string[] {
   const args: string[] = []
@@ -745,8 +812,12 @@ function buildLweArgs(
   const ambientVolume = getAmbientVolume()
   if (ambientVolume !== null) args.push('--ambient-volume', String(ambientVolume))
   if (getDisablePuppetAnimation()) args.push('--render-debug', 'no-puppet-animation')
+  if (getDisableAnimations() && supportsDisableAnimations()) args.push('--disable-animations')
   if (options.scalingMode) args.push('--scaling', options.scalingMode)
   if (options.zoom !== undefined) args.push('--zoom', String(options.zoom))
+  if (options.offsetX !== undefined || options.offsetY !== undefined) {
+    args.push('--offset', `${options.offsetX ?? 0},${options.offsetY ?? 0}`)
+  }
   if (options.disableParallax) args.push('--disable-parallax')
   if (options.cornerColor) args.push('--corner-color', options.cornerColor)
   if (options.speed !== undefined) args.push('--speed', String(options.speed))
@@ -754,6 +825,10 @@ function buildLweArgs(
     for (const id of options.disabledObjects ?? []) args.push('--disable-object', id)
     for (const id of options.enabledObjects ?? []) args.push('--enable-object', id)
   }
+  if ((options.disabledEffects?.length || options.enabledEffects?.length) && supportsEffectFlags()) {
+    for (const id of options.disabledEffects ?? []) args.push('--disable-effect', id)
+    for (const id of options.enabledEffects ?? []) args.push('--enable-effect', id)
+  }
   for (const [name, value] of Object.entries(options.propertyOverrides ?? {})) {
     args.push('--set-property', `${name}=${value}`)
   }
@@ -772,6 +847,9 @@ function buildLweArgs(
       args.push('--sound-volume', `${id}=${volume}`)
     }
   }
+  if (options.customArgs?.trim()) {
+    args.push(...parseCustomArgs(options.customArgs))
+  }
   args.push(wallpaperPath)
   return args
 }
@@ -847,7 +925,7 @@ function resolveActivePid(): number | null {
 }
 
 /**
- * Pushes a background swap and/or layer/volume/xray/scaling/zoom/parallax changes to an already-
+ * Pushes a background swap and/or layer/volume/xray/scaling/zoom/offset/parallax changes to an already-
  * running instance via the extended key=value control file. All fields are optional and
  * independent - only the ones provided get written. All fields for one logical change must go
  * through a single call: the engine has only one pending-request slot, so two calls in quick
@@ -867,6 +945,8 @@ export function hotswapLweSettings(options: {
   xray?: boolean
   scaling?: string
   zoom?: number
+  offsetX?: number
+  offsetY?: number
   disableParallax?: boolean
   cornerColor?: string
   speed?: number
@@ -893,6 +973,9 @@ export function hotswapLweSettings(options: {
   if (options.xray !== undefined) lines.push(`xray=${options.xray ? 'on' : 'off'}`)
   if (options.scaling !== undefined) lines.push(`scaling=${options.scaling}`)
   if (options.zoom !== undefined) lines.push(`zoom=${options.zoom}`)
+  if (options.offsetX !== undefined || options.offsetY !== undefined) {
+    lines.push(`offset=${options.offsetX ?? 0},${options.offsetY ?? 0}`)
+  }
   if (options.disableParallax !== undefined) lines.push(`disable-parallax=${options.disableParallax ? 'on' : 'off'}`)
   if (options.cornerColor !== undefined) lines.push(`corner-color=${options.cornerColor}`)
   if (options.speed !== undefined) lines.push(`speed=${options.speed}`)
@@ -929,15 +1012,20 @@ export async function launchLweAsync(
     volume?: number
     disabledObjects?: string[]
     enabledObjects?: string[]
+    disabledEffects?: string[]
+    enabledEffects?: string[]
     propertyOverrides?: Record<string, string>
     xrayFullReveal?: boolean
     scalingMode?: string
     zoom?: number
+    offsetX?: number
+    offsetY?: number
     disableParallax?: boolean
     cornerColor?: string
     speed?: number
     audioSensitivity?: Record<string, number>
     soundVolume?: Record<string, number>
+    customArgs?: string
   } = {}
 ): Promise<void> {
   // Hot-reload if already running. xray has no launch flag and the running instance keeps its own
@@ -945,14 +1033,20 @@ export async function launchLweAsync(
   // parallax/cornerColor/speed/propertyOverrides do have launch flags, but hot-reload doesn't
   // restart the process - the running instance keeps whatever the previous wallpaper set, so they
   // need the same explicit push, defaulting to "no override" when the new wallpaper doesn't set them.
+  // customArgs and effect overrides have no hotswap control-file key, so they always force a restart
   const defaultSensitivity = getDefaultAudioSensitivity()
   if (
     isLweRunning() &&
+    !options.customArgs?.trim() &&
+    !options.disabledEffects?.length &&
+    !options.enabledEffects?.length &&
     hotswapLweSettings({
       path: wallpaperPath,
       xray: options.xrayFullReveal,
       scaling: options.scalingMode ?? 'default',
       zoom: options.zoom ?? 1,
+      offsetX: options.offsetX ?? 0,
+      offsetY: options.offsetY ?? 0,
       disableParallax: options.disableParallax ?? false,
       cornerColor: options.cornerColor ?? '000000',
       speed: options.speed ?? 1,
@@ -1090,6 +1184,45 @@ export async function listLweObjects(wallpaperPath: string): Promise<LweSceneObj
   }
 }
 
+// Matches lines like `  42 - Bloom (object=17 objectName="body" group="Lighting")` printed by --list-effects
+const EFFECT_LINE_RE = /^\s*(\S+)\s-\s(.*)\s\(object=(\S+)\sobjectName="(.*)"\sgroup="(.*)"\)\s*$/
+
+function parseLweEffectList(output: string): LweSceneEffect[] {
+  const effects: LweSceneEffect[] = []
+  for (const line of output.split('\n')) {
+    const m = line.match(EFFECT_LINE_RE)
+    if (m) effects.push({ id: m[1], name: m[2], objectId: m[3], objectName: m[4], group: m[5] })
+  }
+  return effects
+}
+
+export async function listLweEffects(wallpaperPath: string): Promise<LweSceneEffect[]> {
+  const status = getLweStatus()
+  if (!status.installed) throw new Error('linux-wallpaperengine is not installed.')
+  // older builds ignore unknown flags and render the wallpaper instead of exiting
+  if (!supportsEffectFlags()) return []
+
+  const binaryPath = getLweBinaryPath()
+  const args = ['--list-effects']
+  const assetsDir = findWeAssetsDir()
+  if (assetsDir) args.push('--assets-dir', assetsDir)
+  args.push(wallpaperPath)
+
+  const envVars = buildLweEnvVars()
+  try {
+    const { stdout } = await execFileAsync('env', [...envVars, binaryPath, ...args], {
+      timeout: 15_000,
+      maxBuffer: 5 * 1024 * 1024,
+      encoding: 'utf8'
+    })
+    return parseLweEffectList(stdout)
+  } catch (err) {
+    const e = err as Error & { stdout?: string; stderr?: string }
+    const msg = (e.stderr || e.stdout || e.message || String(err)).trim().split('\n').slice(0, 3).join('\n')
+    throw new Error(`Failed to list wallpaper effects: ${msg}`)
+  }
+}
+
 // Matches lines like "  24 - transbig (scale): minvalue=0.9 maxvalue=1.1 frequency=0 smoothing=10"
 // printed by --list-audio-objects
 const AUDIO_OBJECT_LINE_RE =

+ 1 - 1
src/main/services/playlist-player.service.ts

@@ -104,7 +104,7 @@ async function applyCurrent(playlist: Playlist): Promise<void> {
 
   applying = true
   try {
-    await applyWallpaperMeta(wallpaper, { volume })
+    await applyWallpaperMeta(wallpaper, { volume, dependencyPrompt: 'once' })
     setActiveWallpaperId(wallpaperId)
     updateWallpaper(wallpaperId, {
       appliedCount: (wallpaper.appliedCount ?? 0) + 1,

+ 317 - 50
src/main/services/steam.service.ts

@@ -1,4 +1,6 @@
 import * as path from 'path'
+import Store from 'electron-store'
+import { app } from 'electron'
 import { WE_APP_ID, STANDALONE_APP_ID } from '@shared/constants'
 import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
 import { getSteamIdentity } from './config.service'
@@ -75,6 +77,24 @@ export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
 // flat C API directly via koffi FFI. libsteam_api.so is already loaded by
 // steamworks.js, so dlopen just returns the existing handle. Set up lazily
 // after SteamAPI_Init so a load failure can't take down the whole app.
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+type Koffi = typeof import('koffi')
+
+let steamLibHandle: ReturnType<Koffi['load']> | null = null
+
+function getSteamLib(): ReturnType<Koffi['load']> {
+  if (!steamLibHandle) {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const koffi: Koffi = require('koffi')
+    const libPath = path.join(
+      path.dirname(require.resolve('steamworks.js')),
+      'dist', 'linux64', 'libsteam_api.so'
+    ).replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked')
+    steamLibHandle = koffi.load(libPath)
+  }
+  return steamLibHandle
+}
+
 interface UgcFfi {
   setUserItemVote: (ugc: unknown, itemId: bigint, voteUp: boolean) => bigint
   ugcPtr: unknown
@@ -85,12 +105,8 @@ let ugcFfi: UgcFfi | null = null
 function getUgcFfi(): UgcFfi {
   if (!ugcFfi) {
     // eslint-disable-next-line @typescript-eslint/no-require-imports
-    const koffi = require('koffi')
-    const libPath = path.join(
-      path.dirname(require.resolve('steamworks.js')),
-      'dist', 'linux64', 'libsteam_api.so'
-    ).replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked')
-    const steamLib = koffi.load(libPath)
+    const koffi: Koffi = require('koffi')
+    const steamLib = getSteamLib()
     const ugcPtrType = koffi.pointer(koffi.opaque('ISteamUGC'))
     const getSteamUGC = steamLib.func('SteamAPI_SteamUGC_v020', ugcPtrType, [])
     ugcFfi = {
@@ -103,13 +119,73 @@ function getUgcFfi(): UgcFfi {
   return ugcFfi
 }
 
-export function voteOnItem(itemId: bigint, voteUp: boolean): void {
+let isLoggedOnFn: (() => boolean) | null = null
+
+function isSteamOnline(): boolean {
+  if (!isLoggedOnFn) {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const koffi: Koffi = require('koffi')
+    const steamLib = getSteamLib()
+    const userPtrType = koffi.pointer(koffi.opaque('ISteamUser'))
+    const getSteamUser = steamLib.func('SteamAPI_SteamUser_v023', userPtrType, [])
+    const bLoggedOn = steamLib.func('SteamAPI_ISteamUser_BLoggedOn', 'bool', [userPtrType])
+    const userPtr = getSteamUser()
+    isLoggedOnFn = () => bLoggedOn(userPtr)
+  }
+  return isLoggedOnFn()
+}
+
+function sleep(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+// steamworks.js async calls have no timeout and can hang forever if Steam stalls
+function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
+    promise.then(
+      (value) => {
+        clearTimeout(timer)
+        resolve(value)
+      },
+      (err) => {
+        clearTimeout(timer)
+        reject(err)
+      }
+    )
+  })
+}
+
+// steamworks.js has no vote bindings; this app's fork adds them (patches/steamworks-vote/), feature-detected so unpatched builds still work
+interface PatchedVoteWorkshop {
+  voteItem(itemId: bigint, voteUp: boolean): Promise<void>
+  getUserVote(itemId: bigint): Promise<{ votedUp: boolean; votedDown: boolean; voteSkipped: boolean }>
+}
+
+function getPatchedVoteApi(client: ReturnType<typeof getClient>): PatchedVoteWorkshop | null {
+  const workshop = client.workshop as unknown as Partial<PatchedVoteWorkshop>
+  if (typeof workshop.voteItem === 'function' && typeof workshop.getUserVote === 'function') {
+    return workshop as PatchedVoteWorkshop
+  }
+  return null
+}
+
+export async function checkUserVote(
+  itemId: bigint
+): Promise<{ votedUp: boolean; votedDown: boolean } | null> {
+  const patched = getPatchedVoteApi(getClient())
+  if (!patched) return null
+  const result = await patched.getUserVote(itemId)
+  return { votedUp: result.votedUp, votedDown: result.votedDown }
+}
+
+export function voteOnItem(itemId: bigint, voteUp: boolean): bigint {
   getClient() // ensure Steam is initialized before touching the flat API
   const ffi = getUgcFfi()
   const handle = ffi.setUserItemVote(ffi.ugcPtr, itemId, voteUp)
   // k_uAPICallInvalid = 0 means the call failed immediately
   if (handle === BigInt(0)) throw new Error('SetUserItemVote failed')
-  invalidateVoteCache()
+  return handle
 }
 
 export function openWorkshopItemOverlay(itemId: bigint): void {
@@ -118,40 +194,242 @@ export function openWorkshopItemOverlay(itemId: bigint): void {
   )
 }
 
-// Cached for the process lifetime; invalidateVoteCache() is called after each vote, and
-// startVotedItemsSync() below periodically replaces it wholesale in the background so votes
-// cast outside this app (Steam client, community website) show up without a restart.
-let votedUpCache: Set<string> | null = null
-
-async function fetchVotedUpItems(): Promise<Set<string>> {
+const USER_ITEM_TYPE = 13 // UGCType.All, since WE items span multiple subtypes
+const CREATION_ORDER_DESC = 1
+const VOTED_UP = 2
+const VOTED_DOWN = 3
+const PAGE_FETCH_TIMEOUT_MS = 8000
+const VOTE_TIMEOUT_MS = 15000
+
+async function getUserItemsPage(
+  accountId: number,
+  listType: number,
+  page: number,
+  retries = 2
+): Promise<{ items: Array<{ publishedFileId: bigint } | null | undefined>; returnedResults: number; totalResults: number }> {
   const c = getClient()
-  const accountId = c.localplayer.getSteamId().accountId
+  for (let attempt = 0; ; attempt++) {
+    try {
+      return await withTimeout(
+        c.workshop.getUserItems(
+          page, accountId, listType, USER_ITEM_TYPE, CREATION_ORDER_DESC, { consumer: WE_APP_ID }
+        ),
+        PAGE_FETCH_TIMEOUT_MS,
+        `getUserItems page ${page}`
+      )
+    } catch (err) {
+      if (attempt >= retries) throw err
+      await sleep(300 * (attempt + 1))
+    }
+  }
+}
+
+interface VoteCacheStore {
+  accountId: number | null
+  up: string[]
+  missing: string[]
+  lastFullSync: number
+  pending: Record<string, { up: boolean; at: number }>
+}
+
+const voteStore = new Store<VoteCacheStore>({
+  name: 'vote-cache',
+  defaults: { accountId: null, up: [], missing: [], lastFullSync: 0, pending: {} }
+})
+
+let votedUpCache = new Set<string>(voteStore.get('up'))
+
+// an id must be missing on two consecutive full crawls before it is dropped, to absorb pagination drift
+let missingLastCycle = new Set<string>(voteStore.get('missing'))
+
+// Steam's voted-up list lags behind the vote itself, so local votes stay authoritative until the crawl agrees or this window runs out
+const PENDING_VOTE_TTL_MS = 15 * 60 * 1000
+const pendingVotes = new Map<string, { up: boolean; at: number }>(
+  Object.entries(voteStore.get('pending')).filter(([, v]) => Date.now() - v.at <= PENDING_VOTE_TTL_MS)
+)
+
+function applyPendingVotes(fresh: Set<string>): Set<string> {
+  const now = Date.now()
+  const merged = new Set(fresh)
+  for (const [id, vote] of pendingVotes) {
+    if (fresh.has(id) === vote.up || now - vote.at > PENDING_VOTE_TTL_MS) {
+      pendingVotes.delete(id)
+      continue
+    }
+    if (vote.up) merged.add(id)
+    else merged.delete(id)
+  }
+  return merged
+}
+
+const PERSIST_DELAY_MS = 3000
+let persistTimer: ReturnType<typeof setTimeout> | null = null
+let persistDirty = false
+
+function persistVoteCache(): void {
+  persistDirty = true
+  if (persistTimer) return
+  persistTimer = setTimeout(flushVoteCache, PERSIST_DELAY_MS)
+}
+
+function flushVoteCache(): void {
+  if (persistTimer) {
+    clearTimeout(persistTimer)
+    persistTimer = null
+  }
+  if (!persistDirty) return
+  persistDirty = false
+  voteStore.set('up', [...votedUpCache])
+  voteStore.set('missing', [...missingLastCycle])
+  voteStore.set('pending', Object.fromEntries(pendingVotes))
+}
+
+app.on('will-quit', flushVoteCache)
+
+const VOTE_FETCH_CONCURRENCY = 3
+const PAGE_PACING_MS = 150
+
+// list sort orders use the item's publish date, not the vote date, so the only way to get the full liked list is to crawl all of it
+async function fetchVotedItems(
+  accountId: number,
+  listType: number,
+  onPage: (ids: string[]) => void
+): Promise<Set<string>> {
   const ids = new Set<string>()
-  let page = 1
-  while (true) {
-    const r = await c.workshop.getUserItems(
-      page, accountId,
-      2 /* VotedUp */, 13 /* UGCType.All, since WE items span multiple subtypes */,
-      1 /* CreationOrderDesc */,
-      { consumer: WE_APP_ID }
-    )
-    for (const item of r.items) {
-      if (item) ids.add(item.publishedFileId.toString())
+  const take = (items: Array<{ publishedFileId: bigint } | null | undefined>): void => {
+    const pageIds: string[] = []
+    for (const item of items) if (item) pageIds.push(item.publishedFileId.toString())
+    for (const id of pageIds) ids.add(id)
+    onPage(pageIds)
+  }
+
+  const first = await getUserItemsPage(accountId, listType, 1)
+  take(first.items)
+
+  const pageSize = first.returnedResults
+  if (pageSize <= 0 || ids.size >= first.totalResults) return ids
+
+  const totalPages = Math.ceil(first.totalResults / pageSize)
+  const remainingPages = Array.from({ length: Math.max(0, totalPages - 1) }, (_, i) => i + 2)
+  let cursor = 0
+
+  async function worker(): Promise<void> {
+    while (cursor < remainingPages.length) {
+      const page = remainingPages[cursor++]
+      const r = await getUserItemsPage(accountId, listType, page)
+      take(r.items)
+      await sleep(PAGE_PACING_MS)
     }
-    if (r.returnedResults === 0 || ids.size >= r.totalResults) break
-    page++
   }
+  await Promise.all(
+    Array.from({ length: Math.min(VOTE_FETCH_CONCURRENCY, remainingPages.length) }, worker)
+  )
+
   return ids
 }
 
-export async function getVotedUpItemIds(): Promise<string[]> {
-  if (votedUpCache) return [...votedUpCache]
-  votedUpCache = await fetchVotedUpItems()
-  return [...votedUpCache]
+const FULL_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000
+const SYNC_CHECK_INTERVAL_MS = 10 * 60 * 1000
+const SYNC_STARTUP_DELAY_MS = 20 * 1000
+const PROGRESS_NOTIFY_INTERVAL_MS = 15 * 1000
+
+let votedIdsChangeListener: ((ids: string[]) => void) | null = null
+let syncInFlight = false
+let lastSyncFailure = 0
+
+function notifyVotedIds(): void {
+  votedIdsChangeListener?.([...votedUpCache])
+}
+
+async function runFullSync(): Promise<void> {
+  const accountId = getClient().localplayer.getSteamId().accountId
+  if (voteStore.get('accountId') !== null && voteStore.get('accountId') !== accountId) {
+    votedUpCache = new Set()
+    missingLastCycle = new Set()
+    pendingVotes.clear()
+    notifyVotedIds()
+  }
+  voteStore.set('accountId', accountId)
+
+  let lastNotify = Date.now()
+  const seen = await fetchVotedItems(accountId, VOTED_UP, (pageIds) => {
+    let added = false
+    for (const id of pageIds) {
+      if (!votedUpCache.has(id) && pendingVotes.get(id)?.up !== false) {
+        votedUpCache.add(id)
+        added = true
+      }
+    }
+    if (!added) return
+    persistVoteCache()
+    if (Date.now() - lastNotify >= PROGRESS_NOTIFY_INTERVAL_MS) {
+      lastNotify = Date.now()
+      notifyVotedIds()
+    }
+  })
+
+  const fresh = applyPendingVotes(seen)
+  const merged = new Set(fresh)
+  const missingThisCycle = new Set<string>()
+  for (const id of votedUpCache) {
+    if (fresh.has(id)) continue
+    if (missingLastCycle.has(id)) continue
+    merged.add(id)
+    missingThisCycle.add(id)
+  }
+  const changed = !sameIds(votedUpCache, merged)
+  missingLastCycle = missingThisCycle
+  votedUpCache = merged
+  voteStore.set('lastFullSync', Date.now())
+  persistVoteCache()
+  if (changed) notifyVotedIds()
+  console.log(`[Steam] Liked list synced: ${votedUpCache.size} items`)
+}
+
+function maybeRunFullSync(): void {
+  if (syncInFlight || !isSteamRunning()) return
+  const now = Date.now()
+  if (now - voteStore.get('lastFullSync') < FULL_SYNC_INTERVAL_MS) return
+  if (now - lastSyncFailure < SYNC_CHECK_INTERVAL_MS) return
+  syncInFlight = true
+  runFullSync()
+    .catch((err) => {
+      lastSyncFailure = Date.now()
+      console.warn('[Steam] Liked list sync failed, will retry later:', err?.message ?? err)
+    })
+    .finally(() => {
+      syncInFlight = false
+    })
+}
+
+function recordVote(idStr: string, voteUp: boolean): void {
+  pendingVotes.set(idStr, { up: voteUp, at: Date.now() })
+  if (voteUp) votedUpCache.add(idStr)
+  else votedUpCache.delete(idStr)
+  missingLastCycle.delete(idStr)
+  persistVoteCache()
 }
 
-export function invalidateVoteCache(): void {
-  votedUpCache = null
+// Without the patched steamworks.js, success is reported once SetUserItemVote is enqueued: its result can't be read back,
+// because steamworks.js's manual dispatch loop consumes every call result on the shared Steam pipe first.
+export async function voteOnItemAndConfirm(itemId: bigint, voteUp: boolean): Promise<boolean> {
+  if (!isSteamRunning()) throw new Error('Steam is not running - start Steam to like wallpapers')
+  const client = getClient()
+  if (!isSteamOnline()) throw new Error('Steam is offline - check your internet connection and try again')
+  const idStr = itemId.toString()
+  const patched = getPatchedVoteApi(client)
+
+  if (patched) {
+    await withTimeout(patched.voteItem(itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote').catch((err) => {
+      throw new Error(`Could not reach Steam to ${voteUp ? 'like' : 'dislike'} this wallpaper: ${err?.message ?? err}`)
+    })
+    recordVote(idStr, voteUp)
+    return true
+  }
+
+  voteOnItem(itemId, voteUp)
+  recordVote(idStr, voteUp)
+  return true
 }
 
 function sameIds(a: Set<string>, b: Set<string>): boolean {
@@ -160,28 +438,17 @@ function sameIds(a: Set<string>, b: Set<string>): boolean {
   return true
 }
 
-const VOTE_SYNC_INTERVAL_MS = 3 * 60 * 1000
+export async function getVotedUpItemIds(): Promise<string[]> {
+  return [...votedUpCache]
+}
 
-// Background reconciliation for the vote cache: an in-app vote already updates the renderer
-// optimistically (see WorkshopCard/WallpaperCard onLiked handlers), so this isn't on the
-// critical path for your own votes - it's what catches up on votes cast elsewhere (Steam
-// client, community website) and settles the rare case where SetUserItemVote's async call
-// didn't actually land before the optimistic update fired.
 let voteSyncTimer: ReturnType<typeof setInterval> | null = null
 
 export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
+  votedIdsChangeListener = onChange
   if (voteSyncTimer) return
-  voteSyncTimer = setInterval(async () => {
-    if (!isSteamRunning()) return
-    try {
-      const fresh = await fetchVotedUpItems()
-      const changed = !votedUpCache || !sameIds(votedUpCache, fresh)
-      votedUpCache = fresh
-      if (changed) onChange([...fresh])
-    } catch {
-      // transient Steam hiccup - just try again next tick
-    }
-  }, VOTE_SYNC_INTERVAL_MS)
+  setTimeout(maybeRunFullSync, SYNC_STARTUP_DELAY_MS)
+  voteSyncTimer = setInterval(maybeRunFullSync, SYNC_CHECK_INTERVAL_MS)
 }
 
 export function getSubscribedItems(): string[] {

+ 15 - 2
src/main/services/wallpaper.service.ts

@@ -6,6 +6,7 @@ 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 { ensureDependencyInstalled } from './dependency.service'
 import { getDefaultFps, getRecommendedFpsEnabled, getRecommendedWebFpsEnabled } from './config.service'
 import { findWallpaperVideoFile, getVideoFps } from '../utils/video'
 import { RECOMMENDED_WEB_FPS } from '@shared/constants'
@@ -202,10 +203,17 @@ function findWallpaperImage(dirPath: string): string {
  */
 export async function applyWallpaperMeta(
   wallpaper: WallpaperMeta,
-  options: { fps?: number; volume?: number; backend?: WallpaperBackend } = {}
+  options: {
+    fps?: number
+    volume?: number
+    backend?: WallpaperBackend
+    dependencyPrompt?: 'always' | 'once'
+  } = {}
 ): Promise<string> {
   if (!wallpaper.localPath) throw new Error(`Wallpaper ${wallpaper.id} has no local path`)
 
+  await ensureDependencyInstalled(wallpaper, options.dependencyPrompt)
+
   const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
   const lwe = getLweStatus()
 
@@ -225,18 +233,23 @@ export async function applyWallpaperMeta(
       volume,
       disabledObjects: wallpaper.disabledObjects,
       enabledObjects: wallpaper.enabledObjects,
+      disabledEffects: wallpaper.disabledEffects,
+      enabledEffects: wallpaper.enabledEffects,
       propertyOverrides: wallpaper.propertyOverrides,
       // Explicit even when off: xray state lives on the running LWE process, not per-wallpaper,
       // so a hot-reload into a wallpaper with xray off must actively clear a previous wallpaper's "on".
       xrayFullReveal: wallpaper.xrayFullReveal ?? false,
       scalingMode: wallpaper.scalingMode,
       zoom: wallpaper.zoom,
+      offsetX: wallpaper.offsetX,
+      offsetY: wallpaper.offsetY,
       // Explicit even when off, same reasoning as xrayFullReveal above.
       disableParallax: wallpaper.disableParallax ?? false,
       cornerColor: wallpaper.cornerColor,
       speed: wallpaper.playbackSpeed,
       audioSensitivity: wallpaper.audioSensitivity,
-      soundVolume: wallpaper.soundVolume
+      soundVolume: wallpaper.soundVolume,
+      customArgs: wallpaper.customArgs
     })
     return wallpaper.localPath
   }

+ 10 - 2
src/preload/index.ts

@@ -18,6 +18,7 @@ import type {
   LweSceneObject,
   LweProperty,
   LweAudioObject,
+  LweSceneEffect,
   BackupProgressEvent,
   Playlist,
   PlaylistSettings,
@@ -52,7 +53,7 @@ const api = {
       ipcRenderer.invoke(IpcChannels.STEAM_DOWNLOAD_INFO, itemId),
     itemState: (itemId: string): Promise<number> =>
       ipcRenderer.invoke(IpcChannels.STEAM_ITEM_STATE, itemId),
-    vote: (itemId: string, voteUp: boolean): Promise<{ ok: boolean }> =>
+    vote: (itemId: string, voteUp: boolean): Promise<{ ok: boolean; confirmed: boolean }> =>
       ipcRenderer.invoke(IpcChannels.STEAM_VOTE, itemId, voteUp),
     openWorkshopItem: (itemId: string): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.STEAM_OPEN_WORKSHOP, itemId),
@@ -113,6 +114,7 @@ const api = {
       ambientVolume: number | null
       defaultAudioSensitivity: number
       disablePuppetAnimation: boolean
+      disableAnimations: boolean
       steamIdentity: SteamIdentity
       ignoredCreators: string[]
     }> => ipcRenderer.invoke(IpcChannels.CONFIG_GET),
@@ -160,6 +162,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY, multiplier),
     setDisablePuppetAnimation: (disabled: boolean): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_DISABLE_PUPPET_ANIMATION, disabled),
+    setDisableAnimations: (disabled: boolean): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_DISABLE_ANIMATIONS, disabled),
     setSteamIdentity: (identity: SteamIdentity): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_STEAM_IDENTITY, identity),
     ignoreCreator: (steamId: string): Promise<{ ok: boolean }> =>
@@ -260,6 +264,8 @@ const api = {
       xray?: boolean
       scaling?: string
       zoom?: number
+      offsetX?: number
+      offsetY?: number
       disableParallax?: boolean
       cornerColor?: string
       speed?: number
@@ -275,7 +281,9 @@ const api = {
     listScreens: (): Promise<string[]> =>
       ipcRenderer.invoke(IpcChannels.LWE_LIST_SCREENS),
     listAudioObjects: (wallpaperPath: string): Promise<LweAudioObject[]> =>
-      ipcRenderer.invoke(IpcChannels.LWE_LIST_AUDIO_OBJECTS, wallpaperPath)
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_AUDIO_OBJECTS, wallpaperPath),
+    listEffects: (wallpaperPath: string): Promise<LweSceneEffect[]> =>
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_EFFECTS, wallpaperPath)
   },
 
   backup: {

+ 332 - 33
src/renderer/src/components/common/DetailSidebar.tsx

@@ -27,17 +27,29 @@ import {
   Maximize,
   ZoomIn,
   ZoomOut,
+  Move,
   Move3d,
   Palette,
   Gauge,
   Zap,
-  Music2
+  Music2,
+  Terminal,
+  Sparkles,
+  CloudFog
 } from 'lucide-react'
 import clsx from 'clsx'
 import { openWorkshopPage, openProfilePage, isWorkshopId } from '../../utils/steam'
 import { WE_TYPES, WE_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
 import { formatFileSize } from '../../utils/format'
-import type { LweSceneObject, LweProperty, LweAudioObject, WallpaperMeta, ScalingMode } from '@shared/types'
+import { useToast } from './Toast'
+import type {
+  LweSceneObject,
+  LweSceneEffect,
+  LweProperty,
+  LweAudioObject,
+  WallpaperMeta,
+  ScalingMode
+} from '@shared/types'
 
 const SCALING_MODE_OPTIONS: { value: ScalingMode; label: string }[] = [
   { value: 'default', label: 'Default' },
@@ -129,6 +141,50 @@ function LayerRow({
   )
 }
 
+function EffectRow({
+  effect,
+  state,
+  busy,
+  onToggle
+}: {
+  effect: LweSceneEffect
+  state: ObjectState
+  busy: boolean
+  onToggle: () => void
+}) {
+  return (
+    <button
+      onClick={onToggle}
+      disabled={busy}
+      title={
+        state === 'default'
+          ? 'Visible (default) - click to disable'
+          : state === 'hidden'
+            ? 'Disabled - click to force visible'
+            : 'Forced visible - click to reset to default'
+      }
+      className={clsx(
+        'flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-xs transition-colors hover:bg-white/5 disabled:opacity-40',
+        state === 'hidden' ? 'text-gray-600' : 'text-gray-300'
+      )}
+    >
+      {state === 'hidden' ? (
+        <EyeOff size={12} className="shrink-0 text-gray-600" />
+      ) : state === 'forced' ? (
+        <Pin size={12} className="shrink-0 text-indigo-400" />
+      ) : (
+        <Eye size={12} className="shrink-0 text-gray-500" />
+      )}
+      <span className={clsx('flex-1 truncate', state === 'hidden' && 'line-through')}>
+        {effect.name}
+      </span>
+      <span className="shrink-0 truncate text-gray-600" title={effect.objectName}>
+        on {effect.objectName}
+      </span>
+    </button>
+  )
+}
+
 // Only booleans/sliders/combos are user-facing toggles in the WE customize UI - color/
 // text/file/scene-texture/textinput properties are left out of the sidebar entirely.
 function PropertyRow({
@@ -345,6 +401,7 @@ export default function DetailSidebar({
 }: DetailSidebarProps) {
   const workshopId = isWorkshopId(id)
   const queryClient = useQueryClient()
+  const { showToast } = useToast()
   const [isLiking, setIsLiking] = useState(false)
   const [isDisliking, setIsDisliking] = useState(false)
   const [isApplying, setIsApplying] = useState(false)
@@ -352,6 +409,9 @@ export default function DetailSidebar({
   const [isUnsubscribing, setIsUnsubscribing] = useState(false)
   const [layersOpen, setLayersOpen] = useState(false)
   const [togglingObjectId, setTogglingObjectId] = useState<string | null>(null)
+  const [effectsOpen, setEffectsOpen] = useState(false)
+  const [togglingEffectId, setTogglingEffectId] = useState<string | null>(null)
+  const [togglingFog, setTogglingFog] = useState(false)
   const [propertiesOpen, setPropertiesOpen] = useState(false)
   const [committingProperty, setCommittingProperty] = useState<string | null>(null)
   const [audioOpen, setAudioOpen] = useState(false)
@@ -406,6 +466,18 @@ export default function DetailSidebar({
     retry: false
   })
 
+  const { data: effects = [], isLoading: effectsLoading } = useQuery({
+    queryKey: ['lwe-effects', localPath],
+    queryFn: () => window.electronAPI.lwe.listEffects(localPath!),
+    enabled: !!localPath && lweInstalled,
+    staleTime: Infinity,
+    retry: false
+  })
+  const disabledEffects = libraryMeta?.disabledEffects ?? []
+  const enabledEffects = libraryMeta?.enabledEffects ?? []
+  const fogObjects = objects.filter((o) => /fog/i.test(o.name))
+  const fogDisabled = fogObjects.length > 0 && fogObjects.every((o) => disabledObjects.includes(o.id))
+
   const { data: allProperties = [], isLoading: propertiesLoading } = useQuery({
     queryKey: ['lwe-properties', localPath],
     queryFn: () => window.electronAPI.lwe.listProperties(localPath!),
@@ -449,20 +521,25 @@ export default function DetailSidebar({
     queryClient.setQueryData(['library-item', id], updated)
     queryClient.invalidateQueries({ queryKey: ['library'] })
     if (isActive) {
-      const { ok } = await window.electronAPI.lwe.hotswapSettings({
-        disabledObjects: patch.disabledObjects,
-        enabledObjects: patch.enabledObjects,
-        volume: patch.volumeOverride,
-        xray: patch.xrayFullReveal,
-        scaling: patch.scalingMode,
-        zoom: patch.zoom,
-        disableParallax: patch.disableParallax,
-        cornerColor: patch.cornerColor,
-        speed: patch.playbackSpeed,
-        propertyOverrides: patch.propertyOverrides,
-        audioSensitivity: patch.audioSensitivity,
-        soundVolume: patch.soundVolume
-      })
+      // customArgs and effect overrides have no hotswap control-file key, so they always force a restart
+      const { ok } = 'customArgs' in patch || 'disabledEffects' in patch || 'enabledEffects' in patch
+        ? { ok: false }
+        : await window.electronAPI.lwe.hotswapSettings({
+            disabledObjects: patch.disabledObjects,
+            enabledObjects: patch.enabledObjects,
+            volume: patch.volumeOverride,
+            xray: patch.xrayFullReveal,
+            scaling: patch.scalingMode,
+            zoom: patch.zoom,
+            offsetX: patch.offsetX,
+            offsetY: patch.offsetY,
+            disableParallax: patch.disableParallax,
+            cornerColor: patch.cornerColor,
+            speed: patch.playbackSpeed,
+            propertyOverrides: patch.propertyOverrides,
+            audioSensitivity: patch.audioSensitivity,
+            soundVolume: patch.soundVolume
+          })
       if (!ok) {
         await window.electronAPI.lwe.stop()
         await window.electronAPI.wallpaper.apply({ wallpaperId: id })
@@ -476,7 +553,13 @@ export default function DetailSidebar({
   // 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 }) {
+  function previewLive(patch: {
+    volume?: number
+    zoom?: number
+    offsetX?: number
+    offsetY?: number
+    speed?: number
+  }) {
     if (!isActive) return
     if (livePreviewTimer.current) clearTimeout(livePreviewTimer.current)
     livePreviewTimer.current = setTimeout(() => {
@@ -504,6 +587,46 @@ export default function DetailSidebar({
     }
   }
 
+  async function toggleFog() {
+    if (togglingFog || fogObjects.length === 0) return
+    const disabled = new Set(disabledObjects)
+    const enabled = new Set(enabledObjects)
+    for (const obj of fogObjects) {
+      if (fogDisabled) {
+        disabled.delete(obj.id)
+      } else {
+        disabled.add(obj.id)
+        enabled.delete(obj.id)
+      }
+    }
+    setTogglingFog(true)
+    try {
+      await persistAndMaybeRelaunch({ disabledObjects: [...disabled], enabledObjects: [...enabled] })
+    } finally {
+      setTogglingFog(false)
+    }
+  }
+
+  async function toggleEffect(effectId: string) {
+    if (togglingEffectId) return
+    const disabled = new Set(disabledEffects)
+    const enabled = new Set(enabledEffects)
+    if (disabled.has(effectId)) {
+      disabled.delete(effectId)
+      enabled.add(effectId)
+    } else if (enabled.has(effectId)) {
+      enabled.delete(effectId)
+    } else {
+      disabled.add(effectId)
+    }
+    setTogglingEffectId(effectId)
+    try {
+      await persistAndMaybeRelaunch({ disabledEffects: [...disabled], enabledEffects: [...enabled] })
+    } finally {
+      setTogglingEffectId(null)
+    }
+  }
+
   async function commitProperty(name: string, value: string) {
     if (committingProperty) return
     setCommittingProperty(name)
@@ -588,6 +711,23 @@ export default function DetailSidebar({
     }
   }
 
+  const [offsetXDraft, setOffsetXDraft] = useState<number | null>(null)
+  const [offsetYDraft, setOffsetYDraft] = useState<number | null>(null)
+  const [isCommittingOffset, setIsCommittingOffset] = useState(false)
+  const offsetX = offsetXDraft ?? libraryMeta?.offsetX ?? 0
+  const offsetY = offsetYDraft ?? libraryMeta?.offsetY ?? 0
+
+  async function commitOffset(x: number, y: number) {
+    setIsCommittingOffset(true)
+    try {
+      await persistAndMaybeRelaunch({ offsetX: x, offsetY: y })
+    } finally {
+      setOffsetXDraft(null)
+      setOffsetYDraft(null)
+      setIsCommittingOffset(false)
+    }
+  }
+
   const [fpsDraft, setFpsDraft] = useState<number | null>(null)
   const [isCommittingFps, setIsCommittingFps] = useState(false)
   const fps = fpsDraft ?? libraryMeta?.fpsOverride ?? null
@@ -644,6 +784,24 @@ export default function DetailSidebar({
     }
   }
 
+  const [customArgsDraft, setCustomArgsDraft] = useState<string | null>(null)
+  const [isCommittingCustomArgs, setIsCommittingCustomArgs] = useState(false)
+  const customArgs = customArgsDraft ?? libraryMeta?.customArgs ?? ''
+
+  async function commitCustomArgs(value: string) {
+    if (value === (libraryMeta?.customArgs ?? '')) {
+      setCustomArgsDraft(null)
+      return
+    }
+    setIsCommittingCustomArgs(true)
+    try {
+      await persistAndMaybeRelaunch({ customArgs: value || undefined })
+    } finally {
+      setCustomArgsDraft(null)
+      setIsCommittingCustomArgs(false)
+    }
+  }
+
   const typeTag = tags.find((t) => TYPE_TAGS.has(t))
   const ageTag = tags.find((t) => AGE_TAGS.has(t))
   const resolutionTags = tags.filter((t) => RESOLUTION_TAGS.has(t))
@@ -658,8 +816,14 @@ export default function DetailSidebar({
     if (isLiked || isLiking || unavailable) return
     setIsLiking(true)
     try {
-      await window.electronAPI.steam.vote(id, true)
-      onLiked()
+      const { confirmed } = await window.electronAPI.steam.vote(id, true)
+      if (confirmed) {
+        onLiked()
+      } else {
+        showToast('Could not confirm the like on Steam - try again')
+      }
+    } catch (err) {
+      showToast((err as Error).message)
     } finally {
       setIsLiking(false)
     }
@@ -669,7 +833,10 @@ export default function DetailSidebar({
     if (isDisliking || unavailable) return
     setIsDisliking(true)
     try {
-      await window.electronAPI.steam.vote(id, false)
+      const { confirmed } = await window.electronAPI.steam.vote(id, false)
+      if (!confirmed) showToast('Could not confirm the dislike on Steam - try again')
+    } catch (err) {
+      showToast((err as Error).message)
     } finally {
       setIsDisliking(false)
     }
@@ -772,20 +939,36 @@ export default function DetailSidebar({
 
         {localPath && lweInstalled && (objectsLoading || objects.length > 0) && (
           <div className="border-t border-white/5 pt-3">
-            <button
-              onClick={() => setLayersOpen((v) => !v)}
-              className="flex w-full items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300"
-            >
-              {layersOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
-              <Layers size={12} />
-              <span className="flex-1 text-left">Layers</span>
-              {isActive && (
-                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
-                  live
-                </span>
+            <div className="flex items-center gap-1.5">
+              <button
+                onClick={() => setLayersOpen((v) => !v)}
+                className="flex flex-1 items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300"
+              >
+                {layersOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+                <Layers size={12} />
+                <span className="flex-1 text-left">Layers</span>
+                {isActive && (
+                  <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                    live
+                  </span>
+                )}
+                {objectsLoading && <Loader2 size={11} className="animate-spin" />}
+              </button>
+              {fogObjects.length > 0 && (
+                <button
+                  onClick={toggleFog}
+                  disabled={togglingFog}
+                  title={fogDisabled ? 'Fog disabled - click to re-enable' : 'Click to disable fog'}
+                  className={clsx(
+                    'flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium normal-case transition-colors disabled:opacity-40',
+                    fogDisabled ? 'bg-white/5 text-gray-500' : 'bg-white/10 text-gray-300 hover:bg-white/15'
+                  )}
+                >
+                  {togglingFog ? <Loader2 size={10} className="animate-spin" /> : <CloudFog size={10} />}
+                  Fog
+                </button>
               )}
-              {objectsLoading && <Loader2 size={11} className="animate-spin" />}
-            </button>
+            </div>
             {layersOpen && (
               <div className="mt-1.5 space-y-0.5">
                 {objects.map((obj) => (
@@ -802,6 +985,33 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {localPath && lweInstalled && (effectsLoading || effects.length > 0) && (
+          <div className="border-t border-white/5 pt-3">
+            <button
+              onClick={() => setEffectsOpen((v) => !v)}
+              className="flex w-full items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300"
+            >
+              {effectsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+              <Sparkles size={12} />
+              <span className="flex-1 text-left">Effects</span>
+              {effectsLoading && <Loader2 size={11} className="animate-spin" />}
+            </button>
+            {effectsOpen && (
+              <div className="mt-1.5 space-y-0.5">
+                {effects.map((fx) => (
+                  <EffectRow
+                    key={fx.id}
+                    effect={fx}
+                    state={objectState(fx.id, disabledEffects, enabledEffects)}
+                    busy={togglingEffectId === fx.id}
+                    onToggle={() => toggleEffect(fx.id)}
+                  />
+                ))}
+              </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">
@@ -862,6 +1072,67 @@ export default function DetailSidebar({
           </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">
+              <Move size={12} />
+              <span className="flex-1 text-left">Position</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              {isCommittingOffset && <Loader2 size={11} className="animate-spin" />}
+            </div>
+            <div className="flex items-center gap-1.5">
+              <span className="w-12 shrink-0 text-[10px] normal-case text-gray-500">Left/Right</span>
+              <input
+                type="range"
+                min={-100}
+                max={100}
+                step={1}
+                value={Math.round(offsetX * 100)}
+                disabled={isCommittingOffset}
+                onChange={(e) => {
+                  const v = Number(e.target.value) / 100
+                  setOffsetXDraft(v)
+                  previewLive({ offsetX: v, offsetY })
+                }}
+                onMouseUp={(e) => commitOffset(Number(e.currentTarget.value) / 100, offsetY)}
+                onTouchEnd={(e) => commitOffset(Number(e.currentTarget.value) / 100, offsetY)}
+                onKeyUp={(e) => commitOffset(Number(e.currentTarget.value) / 100, offsetY)}
+                className="w-full accent-indigo-500 disabled:opacity-50"
+              />
+              <span className="w-8 shrink-0 text-right text-[10px] text-gray-400">
+                {Math.round(offsetX * 100)}
+              </span>
+            </div>
+            <div className="mt-1.5 flex items-center gap-1.5">
+              <span className="w-12 shrink-0 text-[10px] normal-case text-gray-500">Up/Down</span>
+              <input
+                type="range"
+                min={-100}
+                max={100}
+                step={1}
+                value={Math.round(offsetY * 100)}
+                disabled={isCommittingOffset}
+                onChange={(e) => {
+                  const v = Number(e.target.value) / 100
+                  setOffsetYDraft(v)
+                  previewLive({ offsetX, offsetY: v })
+                }}
+                onMouseUp={(e) => commitOffset(offsetX, Number(e.currentTarget.value) / 100)}
+                onTouchEnd={(e) => commitOffset(offsetX, Number(e.currentTarget.value) / 100)}
+                onKeyUp={(e) => commitOffset(offsetX, Number(e.currentTarget.value) / 100)}
+                className="w-full accent-indigo-500 disabled:opacity-50"
+              />
+              <span className="w-8 shrink-0 text-right text-[10px] text-gray-400">
+                {Math.round(offsetY * 100)}
+              </span>
+            </div>
+          </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">
@@ -1056,6 +1327,34 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {localPath && lweInstalled && libraryMeta?.type && libraryMeta.type !== 'application' && (
+          <div className="border-t border-white/5 pt-3">
+            <label className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
+              <Terminal size={12} />
+              <span className="flex-1 text-left">Custom launch arguments</span>
+              {isCommittingCustomArgs && <Loader2 size={11} className="animate-spin" />}
+            </label>
+            <p className="mb-1.5 text-[11px] text-gray-600">
+              Extra linux-wallpaperengine flags for this wallpaper only, e.g.{' '}
+              <code className="text-gray-500">--render-debug skip-effect=726</code>. Applying a
+              change always restarts the wallpaper if it's playing.
+            </p>
+            <input
+              type="text"
+              spellCheck={false}
+              value={customArgs}
+              disabled={isCommittingCustomArgs}
+              onChange={(e) => setCustomArgsDraft(e.target.value)}
+              onBlur={(e) => commitCustomArgs(e.target.value)}
+              onKeyDown={(e) => {
+                if (e.key === 'Enter') e.currentTarget.blur()
+              }}
+              placeholder="--render-debug skip-effect=726"
+              className="w-full rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-gray-200 placeholder:text-gray-600 focus:border-indigo-500/50 focus:outline-none disabled:opacity-50"
+            />
+          </div>
+        )}
+
         {localPath && lweInstalled && (propertiesLoading || properties.length > 0) && (
           <div className="border-t border-white/5 pt-3">
             <button

+ 101 - 8
src/renderer/src/components/library/LibraryView.tsx

@@ -542,15 +542,48 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
   const [backingUpSelection, setBackingUpSelection] = useState(false)
   const [backupScanning, setBackupScanning] = useState(false)
   const [backupStatus, setBackupStatus] = useState<string | null>(null)
+  const [backupProgress, setBackupProgress] = useState<
+    Map<string, { percentage: number; status: 'copying' | 'verifying' }>
+  >(new Map())
+
+  useEffect(() => {
+    return window.electronAPI.on.backupProgress((progress) => {
+      setBackupProgress((prev) => {
+        const next = new Map(prev)
+        if (progress.status === 'copying' || progress.status === 'verifying') {
+          next.set(progress.itemId, { percentage: progress.percentage, status: progress.status })
+        } else {
+          next.delete(progress.itemId)
+        }
+        return next
+      })
+      if (progress.status === 'error') {
+        showToast(`Backup failed: ${progress.message ?? 'verification error'}`)
+      }
+    })
+  }, [])
 
   async function handleBackupSelection() {
     if (selectedIds.size === 0) return
+    const targets = wallpapers.filter((w) => selectedIds.has(w.id))
+    const alreadyBackedUp = targets.filter((w) => w.backedUp)
+    const toBackUp = targets.filter((w) => !w.backedUp)
+
+    if (toBackUp.length === 0) {
+      setBackupStatus(
+        alreadyBackedUp.length === 1
+          ? 'Already backed up.'
+          : `All ${alreadyBackedUp.length} selected wallpapers are already backed up.`
+      )
+      return
+    }
+
     setBackingUpSelection(true)
     setBackupStatus(null)
     try {
-      const result = await window.electronAPI.backup.selection(Array.from(selectedIds))
+      const result = await window.electronAPI.backup.selection(toBackUp.map((w) => w.id))
       setBackupStatus(
-        `Backed up ${result.backedUp}${result.unsubscribed > 0 ? ` (${result.unsubscribed} unsubscribed)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}.`
+        `Backed up ${result.backedUp}${alreadyBackedUp.length > 0 ? `, ${alreadyBackedUp.length} already were` : ''}${result.unsubscribed > 0 ? ` (${result.unsubscribed} unsubscribed)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}.`
       )
     } finally {
       setBackingUpSelection(false)
@@ -745,9 +778,27 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
   async function ctxVote(up: boolean) {
     if (!ctxMenu) return
-    await forEachIgnoringErrors(ctxMenu.ids, (id) => window.electronAPI.steam.vote(id, up))
-    queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
+    const ids = ctxMenu.ids
     closeCtxMenu()
+
+    const results = await Promise.allSettled(ids.map((id) => window.electronAPI.steam.vote(id, up)))
+    const confirmedIds = ids.filter((_, i) => {
+      const r = results[i]
+      return r.status === 'fulfilled' && r.value.confirmed
+    })
+
+    if (up && confirmedIds.length > 0) {
+      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) => [
+        ...new Set([...(old ?? []), ...confirmedIds])
+      ])
+    }
+
+    const failed = ids.length - confirmedIds.length
+    showToast(
+      failed === 0
+        ? `${up ? 'Liked' : 'Disliked'} ${confirmedIds.length} on Steam`
+        : `${up ? 'Liked' : 'Disliked'} ${confirmedIds.length}, ${failed} could not be confirmed`
+    )
   }
 
   function ctxOpenInSteam() {
@@ -778,11 +829,37 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
   async function ctxBackup() {
     if (!ctxMenu) return
+    const targets = ctxWallpapers
     closeCtxMenu()
-    if (ctxMenu.ids.length === 1) {
-      await window.electronAPI.backup.item(ctxMenu.ids[0])
-    } else {
-      await window.electronAPI.backup.selection(ctxMenu.ids)
+
+    const alreadyBackedUp = targets.filter((w) => w.backedUp)
+    const toBackUp = targets.filter((w) => !w.backedUp)
+
+    if (toBackUp.length === 0) {
+      showToast(
+        alreadyBackedUp.length === 1
+          ? 'Already backed up'
+          : `All ${alreadyBackedUp.length} wallpapers are already backed up`
+      )
+      return
+    }
+
+    try {
+      if (toBackUp.length === 1) {
+        await window.electronAPI.backup.item(toBackUp[0].id)
+        showToast(
+          alreadyBackedUp.length > 0
+            ? `Backed up (${alreadyBackedUp.length} already were)`
+            : 'Backed up'
+        )
+      } else {
+        const result = await window.electronAPI.backup.selection(toBackUp.map((w) => w.id))
+        showToast(
+          `Backed up ${result.backedUp}${alreadyBackedUp.length > 0 ? `, ${alreadyBackedUp.length} already were` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`
+        )
+      }
+    } catch (err) {
+      showToast(`Backup failed: ${(err as Error).message}`)
     }
     queryClient.invalidateQueries({ queryKey: ['library'] })
   }
@@ -1113,6 +1190,21 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
         </div>
       )}
 
+      {backupProgress.size > 0 && (
+        <div className="border-b border-white/5 px-4 py-2 text-xs text-gray-400">
+          Backing up {backupProgress.size} wallpaper{backupProgress.size === 1 ? '' : 's'}
+          {Array.from(backupProgress.values()).some((p) => p.status === 'verifying')
+            ? ' (verifying copies)'
+            : ''}
+          ...{' '}
+          {Math.round(
+            Array.from(backupProgress.values()).reduce((sum, p) => sum + p.percentage, 0) /
+              backupProgress.size
+          )}
+          % avg
+        </div>
+      )}
+
       {backupStatus && (
         <div className="border-b border-white/5 px-4 py-2 text-xs text-gray-400">
           {backupStatus}
@@ -1313,6 +1405,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
                 currentPlaylistId={currentPlaylistId}
                 isInCurrentPlaylist={currentPlaylistItemIds.has(wallpaper.id)}
                 previewSize={previewSize}
+                backupProgress={backupProgress.get(wallpaper.id) ?? null}
                 onApplied={() =>
                   queryClient.invalidateQueries({ queryKey: ['library'] })
                 }

+ 25 - 3
src/renderer/src/components/library/WallpaperCard.tsx

@@ -16,6 +16,7 @@ interface WallpaperCardProps {
   currentPlaylistId?: string | null
   isInCurrentPlaylist?: boolean
   previewSize?: PreviewSize
+  backupProgress?: { percentage: number; status: 'copying' | 'verifying' } | null
   onApplied?: () => void
   onLiked?: () => void
   onUnsubscribed?: () => void
@@ -34,6 +35,7 @@ export default function WallpaperCard({
   currentPlaylistId = null,
   isInCurrentPlaylist = false,
   previewSize = 'normal',
+  backupProgress = null,
   onApplied,
   onLiked,
   onUnsubscribed,
@@ -102,8 +104,12 @@ export default function WallpaperCard({
     if (isLiked || isLiking || wallpaper.unavailable) return
     setIsLiking(true)
     try {
-      await window.electronAPI.steam.vote(wallpaper.id, true)
-      onLiked?.()
+      const { confirmed } = await window.electronAPI.steam.vote(wallpaper.id, true)
+      if (confirmed) {
+        onLiked?.()
+      } else {
+        setError('Could not confirm the like on Steam - try again')
+      }
     } catch (err) {
       setError((err as Error).message)
     } finally {
@@ -232,7 +238,23 @@ export default function WallpaperCard({
             </button>
           </div>
         )}
-        {(wallpaper.backedUp || wallpaper.source === 'backup') && (
+        {backupProgress != null && (
+          <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/70 px-4">
+            <Archive size={20} className="text-indigo-400 animate-pulse" />
+            <span className="text-xs text-gray-300">
+              {backupProgress.status === 'verifying'
+                ? `Verifying... ${backupProgress.percentage}%`
+                : `Backing up... ${backupProgress.percentage}%`}
+            </span>
+            <div className="h-1 w-full max-w-[140px] overflow-hidden rounded-full bg-white/10">
+              <div
+                className="h-full bg-indigo-500 transition-[width]"
+                style={{ width: `${backupProgress.percentage}%` }}
+              />
+            </div>
+          </div>
+        )}
+        {backupProgress == null && (wallpaper.backedUp || wallpaper.source === 'backup') && (
           <div
             className="absolute bottom-2 right-2 flex h-5 w-5 items-center justify-center rounded bg-black/60 text-indigo-300"
             title="Backed up locally"

+ 14 - 6
src/renderer/src/components/playlist/PlaylistsView.tsx

@@ -226,13 +226,21 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
   async function ctxVote(up: boolean) {
     if (!ctxMenu) return
     const wallpaperId = ctxMenu.wallpaperId
-    await window.electronAPI.steam.vote(wallpaperId, up)
-    if (up) {
-      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
-        old ? [...new Set([...old, wallpaperId])] : [wallpaperId]
-      )
-    }
     closeCtxMenu()
+    try {
+      const { confirmed } = await window.electronAPI.steam.vote(wallpaperId, up)
+      if (!confirmed) {
+        showToast(`Could not confirm the ${up ? 'like' : 'dislike'} on Steam - try again`)
+        return
+      }
+      if (up) {
+        queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+          old ? [...new Set([...old, wallpaperId])] : [wallpaperId]
+        )
+      }
+    } catch (err) {
+      showToast((err as Error).message)
+    }
   }
 
   function ctxOpenInSteam() {

+ 40 - 1
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect } from '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, Shield, Bug } from 'lucide-react'
+import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull, Film, Globe, Shield, Bug, Pause } from 'lucide-react'
 import type { LweStatus, LweInstallProgress, LinuxDistro, SteamIdentity } from '../../../../shared/types'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
@@ -69,6 +69,7 @@ export default function SettingsView() {
   const [defaultAudioSensitivity, setDefaultAudioSensitivity] = useState<string>('100')
   const [defaultAudioSensitivitySaved, setDefaultAudioSensitivitySaved] = useState(false)
   const [disablePuppetAnimation, setDisablePuppetAnimation] = useState(false)
+  const [disableAnimations, setDisableAnimations] = useState(false)
 
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
@@ -93,6 +94,7 @@ export default function SettingsView() {
       setAmbientVolume(cfg.ambientVolume != null ? String(cfg.ambientVolume) : '')
       setDefaultAudioSensitivity(String(Math.round(cfg.defaultAudioSensitivity * 100)))
       setDisablePuppetAnimation(cfg.disablePuppetAnimation)
+      setDisableAnimations(cfg.disableAnimations)
     })
     window.electronAPI.lwe.status().then(setLweStatus)
     window.electronAPI.lwe.detectDistro().then(setDistro)
@@ -279,6 +281,12 @@ export default function SettingsView() {
     await window.electronAPI.config.setDisablePuppetAnimation(newVal)
   }
 
+  async function handleDisableAnimationsToggle() {
+    const newVal = !disableAnimations
+    setDisableAnimations(newVal)
+    await window.electronAPI.config.setDisableAnimations(newVal)
+  }
+
   async function handleSteamIdentityChange(identity: SteamIdentity) {
     setSteamIdentityState(identity)
     await window.electronAPI.config.setSteamIdentity(identity)
@@ -894,6 +902,37 @@ export default function SettingsView() {
           </div>
         </div>
 
+        <div>
+          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
+            Effects &amp; Animation
+          </label>
+          <p className="mt-1 text-xs text-gray-600">
+            Freezes every wallpaper's scene time - scripts, particles, effects and puppet meshes all stop
+            advancing and stay on whatever frame they were on. Only applies to wallpapers played after enabling
+            this; a wallpaper already running needs to be replayed to pick it up. To disable a specific effect
+            (bloom, fog, glow, etc) on one wallpaper instead of freezing everything everywhere, use the Effects
+            panel on that wallpaper's detail page.
+          </p>
+          <label className="mt-3 flex items-center gap-3 cursor-pointer">
+            <button
+              onClick={handleDisableAnimationsToggle}
+              className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+                disableAnimations ? 'bg-indigo-600' : 'bg-white/10'
+              }`}
+            >
+              <span
+                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                  disableAnimations ? 'translate-x-4' : 'translate-x-0.5'
+                }`}
+              />
+            </button>
+            <span className="flex items-center gap-2 text-sm text-gray-300">
+              <Pause size={16} />
+              Disable wallpaper animation
+            </span>
+          </label>
+        </div>
+
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Troubleshooting

+ 155 - 21
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -51,6 +51,10 @@ interface WorkshopFilterState {
   ageRatings: string[]
   resolutions: string[]
   genres: string[]
+  notDownloaded: boolean
+  notLiked: boolean
+  sizeFilterMode: 'none' | 'lt' | 'gt'
+  sizeFilterMb: number
 }
 
 const ALL_RESOLUTIONS = WE_RESOLUTION_GROUPS.flatMap((g) => g.items.map((i) => i.tag))
@@ -66,7 +70,11 @@ const DEFAULT_STATE: WorkshopFilterState = {
   assetTypes: [],
   ageRatings: ALL_AGE_RATINGS,
   resolutions: ALL_RESOLUTIONS,
-  genres: ALL_GENRES
+  genres: ALL_GENRES,
+  notDownloaded: false,
+  notLiked: false,
+  sizeFilterMode: 'none',
+  sizeFilterMb: 100
 }
 
 function loadFilters(): WorkshopFilterState {
@@ -85,6 +93,20 @@ function saveFilters(state: WorkshopFilterState) {
   localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...state, _v: STORAGE_VERSION }))
 }
 
+function parseSearchQuery(raw: string): { positiveText: string; excludeTerms: string[] } {
+  const tokens = raw.trim().split(/\s+/).filter(Boolean)
+  const positiveTokens: string[] = []
+  const excludeTerms: string[] = []
+  for (const token of tokens) {
+    if (token.length > 1 && token.startsWith('-')) {
+      excludeTerms.push(token.slice(1).toLowerCase())
+    } else {
+      positiveTokens.push(token)
+    }
+  }
+  return { positiveText: positiveTokens.join(' '), excludeTerms }
+}
+
 function CheckItem({
   label,
   checked,
@@ -172,6 +194,10 @@ export default function WorkshopBrowser({
   const { entries: subscribeEntries, enqueue: enqueueSubscribe, clear: clearSubscribeEntries } =
     useSubscriptionQueue(() => queryClient.invalidateQueries({ queryKey: ['library'] }))
   const [searchText, setSearchText] = useState('')
+  const { positiveText: searchPositiveText, excludeTerms: searchExcludeTerms } = useMemo(
+    () => parseSearchQuery(searchText),
+    [searchText]
+  )
   const [queryType, setQueryType] = useState<WorkshopQueryType>('RankedByPublicationDate')
   const [showFilters, setShowFilters] = useState(true)
   const [filters, setFilters] = useState<WorkshopFilterState>(loadFilters)
@@ -200,6 +226,22 @@ export default function WorkshopBrowser({
     setFilters((prev) => ({ ...prev, [key]: toggle(prev[key] as string[], tag) }))
   }
 
+  function toggleNotDownloaded() {
+    setFilters((prev) => ({ ...prev, notDownloaded: !prev.notDownloaded }))
+  }
+
+  function toggleNotLiked() {
+    setFilters((prev) => ({ ...prev, notLiked: !prev.notLiked }))
+  }
+
+  function setSizeFilterMode(mode: WorkshopFilterState['sizeFilterMode']) {
+    setFilters((prev) => ({ ...prev, sizeFilterMode: mode }))
+  }
+
+  function setSizeFilterMb(mb: number) {
+    setFilters((prev) => ({ ...prev, sizeFilterMb: mb }))
+  }
+
   // Having every option of a category selected means no filter for that category
   const effectiveTypes = filters.types.length === ALL_TYPES.length ? [] : filters.types
   const effectiveAssetTypes = filters.assetTypes.length === ALL_ASSET_TYPES.length ? [] : filters.assetTypes
@@ -237,7 +279,10 @@ export default function WorkshopBrowser({
     effectiveAssetTypes.length +
     effectiveAgeRatings.length +
     effectiveResolutions.length +
-    effectiveGenres.length
+    effectiveGenres.length +
+    (filters.notDownloaded ? 1 : 0) +
+    (filters.notLiked ? 1 : 0) +
+    (filters.sizeFilterMode !== 'none' ? 1 : 0)
 
   function clearAll() {
     setFilters((prev) => ({ ...DEFAULT_STATE, filterMode: prev.filterMode }))
@@ -269,13 +314,26 @@ export default function WorkshopBrowser({
   async function ctxVote(up: boolean) {
     if (!ctxMenu) return
     const ids = ctxMenu.ids
-    await forEachIgnoringErrors(ids, (id) => window.electronAPI.steam.vote(id, up))
-    if (up) {
+    closeCtxMenu()
+
+    const results = await Promise.allSettled(ids.map((id) => window.electronAPI.steam.vote(id, up)))
+    const confirmedIds = ids.filter((_, i) => {
+      const r = results[i]
+      return r.status === 'fulfilled' && r.value.confirmed
+    })
+
+    if (up && confirmedIds.length > 0) {
       queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) => [
-        ...new Set([...(old ?? []), ...ids])
+        ...new Set([...(old ?? []), ...confirmedIds])
       ])
     }
-    closeCtxMenu()
+
+    const failed = ids.length - confirmedIds.length
+    showToast(
+      failed === 0
+        ? `${up ? 'Liked' : 'Disliked'} ${confirmedIds.length} on Steam`
+        : `${up ? 'Liked' : 'Disliked'} ${confirmedIds.length}, ${failed} could not be confirmed`
+    )
   }
 
   function ctxOpenInSteam() {
@@ -292,17 +350,17 @@ export default function WorkshopBrowser({
 
   const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } =
     useInfiniteQuery({
-      queryKey: ['workshop', creatorFilter, searchText, queryType, steamTags],
+      queryKey: ['workshop', creatorFilter, searchPositiveText, queryType, steamTags],
       queryFn: ({ pageParam = 1 }) =>
         creatorFilter
           ? window.electronAPI.workshop.queryByCreator(creatorFilter, {
-              searchText: searchText || undefined,
+              searchText: searchPositiveText || undefined,
               queryType,
               tags: steamTags.length > 0 ? steamTags : undefined,
               page: pageParam as number
             })
           : window.electronAPI.workshop.query({
-              searchText: searchText || undefined,
+              searchText: searchPositiveText || undefined,
               queryType,
               tags: steamTags.length > 0 ? steamTags : undefined,
               page: pageParam as number
@@ -420,19 +478,57 @@ export default function WorkshopBrowser({
 
   // In OR mode: client-side OR filtering for multi-selected categories
   const tagFiltered = useMemo(() => {
-    if (filters.filterMode !== 'or') return allItems
-    return allItems.filter((item) => {
-      if (effectiveTypes.length > 1 && !effectiveTypes.some((t) => item.tags.includes(t)))
-        return false
-      if (effectiveAgeRatings.length > 1 && !effectiveAgeRatings.some((t) => item.tags.includes(t)))
-        return false
-      if (effectiveResolutions.length > 1 && !effectiveResolutions.some((t) => item.tags.includes(t)))
-        return false
-      if (effectiveGenres.length > 1 && !effectiveGenres.some((t) => item.tags.includes(t)))
-        return false
+    const tagMatched =
+      filters.filterMode !== 'or'
+        ? allItems
+        : allItems.filter((item) => {
+            if (effectiveTypes.length > 1 && !effectiveTypes.some((t) => item.tags.includes(t)))
+              return false
+            if (
+              effectiveAgeRatings.length > 1 &&
+              !effectiveAgeRatings.some((t) => item.tags.includes(t))
+            )
+              return false
+            if (
+              effectiveResolutions.length > 1 &&
+              !effectiveResolutions.some((t) => item.tags.includes(t))
+            )
+              return false
+            if (effectiveGenres.length > 1 && !effectiveGenres.some((t) => item.tags.includes(t)))
+              return false
+            return true
+          })
+    const sizeThresholdBytes = filters.sizeFilterMb * 1024 * 1024
+
+    return tagMatched.filter((item) => {
+      if (filters.notDownloaded && playableSet.has(item.publishedFileId)) return false
+      if (filters.notLiked && votedSet.has(item.publishedFileId)) return false
+      if (filters.sizeFilterMode !== 'none') {
+        if (item.fileSize == null) return false
+        if (filters.sizeFilterMode === 'lt' && item.fileSize >= sizeThresholdBytes) return false
+        if (filters.sizeFilterMode === 'gt' && item.fileSize <= sizeThresholdBytes) return false
+      }
+      if (searchExcludeTerms.length > 0) {
+        const haystack = `${item.title} ${item.description} ${item.tags.join(' ')}`.toLowerCase()
+        if (searchExcludeTerms.some((term) => haystack.includes(term))) return false
+      }
       return true
     })
-  }, [allItems, filters.filterMode, effectiveTypes, effectiveAgeRatings, effectiveResolutions, effectiveGenres])
+  }, [
+    allItems,
+    searchExcludeTerms,
+    filters.filterMode,
+    filters.notDownloaded,
+    filters.notLiked,
+    filters.sizeFilterMode,
+    filters.sizeFilterMb,
+    effectiveTypes,
+    effectiveAgeRatings,
+    effectiveResolutions,
+    effectiveGenres,
+    playableSet,
+    votedSet
+  ])
 
   // Page boundaries are fixed against the full tag-filtered list (ignored creators' items
   // included) so a page's contents don't shift around as the ignore list changes. Within a
@@ -541,7 +637,8 @@ export default function WorkshopBrowser({
           <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
           <input
             type="text"
-            placeholder="Search wallpapers..."
+            placeholder="Search wallpapers... (-word to exclude)"
+            title="Prefix a word with - to exclude it, e.g. holo -hololive"
             value={searchText}
             onChange={(e) => setSearchText(e.target.value)}
             className="w-full rounded-lg bg-white/5 py-2 pl-9 pr-3 text-sm text-gray-200 placeholder-gray-500 outline-none focus:ring-1 focus:ring-indigo-500"
@@ -630,6 +727,43 @@ export default function WorkshopBrowser({
               </div>
             </div>
 
+            <FilterSection
+              title="Status"
+              activeCount={(filters.notDownloaded ? 1 : 0) + (filters.notLiked ? 1 : 0)}
+            >
+              <CheckItem
+                label="Not downloaded"
+                checked={filters.notDownloaded}
+                onChange={toggleNotDownloaded}
+              />
+              <CheckItem label="Not liked" checked={filters.notLiked} onChange={toggleNotLiked} />
+            </FilterSection>
+            <FilterSection
+              title="File Size"
+              activeCount={filters.sizeFilterMode !== 'none' ? 1 : 0}
+            >
+              <select
+                value={filters.sizeFilterMode}
+                onChange={(e) => setSizeFilterMode(e.target.value as WorkshopFilterState['sizeFilterMode'])}
+                className="w-full rounded bg-[#1a1a1a] px-1.5 py-1 text-xs text-gray-300 outline-none [&>option]:bg-[#1a1a1a] [&>option]:text-gray-300"
+              >
+                <option value="none">Any size</option>
+                <option value="lt">Less than</option>
+                <option value="gt">More than</option>
+              </select>
+              {filters.sizeFilterMode !== 'none' && (
+                <div className="mt-1 flex items-center gap-1.5">
+                  <input
+                    type="number"
+                    min={0}
+                    value={filters.sizeFilterMb}
+                    onChange={(e) => setSizeFilterMb(Math.max(0, Number(e.target.value) || 0))}
+                    className="w-16 rounded bg-[#1a1a1a] px-1.5 py-1 text-xs text-gray-300 outline-none focus:ring-1 focus:ring-indigo-500"
+                  />
+                  <span className="text-xs text-gray-500">MB</span>
+                </div>
+              )}
+            </FilterSection>
             <FilterSection
               title="Show Only"
               defaultOpen={false}

+ 9 - 3
src/renderer/src/components/workshop/WorkshopCard.tsx

@@ -5,6 +5,7 @@ import type { SubscribeState } from '../../hooks/useSubscriptionQueue'
 import clsx from 'clsx'
 import { openWorkshopPage } from '../../utils/steam'
 import { formatFileSize } from '../../utils/format'
+import { useToast } from '../common/Toast'
 
 interface WorkshopCardProps {
   item: WorkshopItem
@@ -39,6 +40,7 @@ export default function WorkshopCard({
   onPlay,
   onSubscribe
 }: WorkshopCardProps) {
+  const { showToast } = useToast()
   const [isLiking, setIsLiking] = useState(false)
   const [isApplying, setIsApplying] = useState(false)
 
@@ -72,10 +74,14 @@ export default function WorkshopCard({
     if (isLiked || isLiking) return
     setIsLiking(true)
     try {
-      await window.electronAPI.steam.vote(item.publishedFileId, true)
-      onLiked?.()
+      const { confirmed } = await window.electronAPI.steam.vote(item.publishedFileId, true)
+      if (confirmed) {
+        onLiked?.()
+      } else {
+        showToast('Could not confirm the like on Steam - try again')
+      }
     } catch (err) {
-      console.error('Like failed:', err)
+      showToast((err as Error).message)
     } finally {
       setIsLiking(false)
     }

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

@@ -61,6 +61,7 @@ export const IpcChannels = {
   CONFIG_SET_AMBIENT_VOLUME: 'config:set-ambient-volume',
   CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY: 'config:set-default-audio-sensitivity',
   CONFIG_SET_DISABLE_PUPPET_ANIMATION: 'config:set-disable-puppet-animation',
+  CONFIG_SET_DISABLE_ANIMATIONS: 'config:set-disable-animations',
   CONFIG_SET_STEAM_IDENTITY: 'config:set-steam-identity',
   CONFIG_IGNORE_CREATOR: 'config:ignore-creator',
   CONFIG_UNIGNORE_CREATOR: 'config:unignore-creator',
@@ -121,6 +122,7 @@ export const IpcChannels = {
   LWE_LIST_PROPERTIES: 'lwe:list-properties',
   LWE_LIST_SCREENS: 'lwe:list-screens',
   LWE_LIST_AUDIO_OBJECTS: 'lwe:list-audio-objects',
+  LWE_LIST_EFFECTS: 'lwe:list-effects',
 
   // Desktop icons overlay
   DESKTOP_ICONS_SET_ENABLED: 'desktop-icons:set-enabled',

+ 24 - 1
src/shared/types.ts

@@ -58,6 +58,9 @@ export interface WallpaperMeta {
   scalingMode?: ScalingMode
   /** Manual zoom layered on top of scalingMode via linux-wallpaperengine --zoom (1 = no extra zoom) */
   zoom?: number
+  /** Re-centers the crop window, each axis in [-1, 1] (0 = centered), via linux-wallpaperengine --offset */
+  offsetX?: number
+  offsetY?: number
   /** Force-disables the scene's mouse parallax effect via linux-wallpaperengine --disable-parallax */
   disableParallax?: boolean
   /**
@@ -77,6 +80,10 @@ export interface WallpaperMeta {
   disabledObjects?: string[]
   /** Object/layer ids or names to force-show via linux-wallpaperengine --enable-object */
   enabledObjects?: string[]
+  /** Effect ids or editor names to force-hide via linux-wallpaperengine --disable-effect */
+  disabledEffects?: string[]
+  /** Effect ids or editor names to force-show via linux-wallpaperengine --enable-effect */
+  enabledEffects?: string[]
   /** Property name -> override value, applied via linux-wallpaperengine --set-property name=value */
   propertyOverrides?: Record<string, string>
   /**
@@ -92,6 +99,11 @@ export interface WallpaperMeta {
    * tracks (each its own Sound object) play only one.
    */
   soundVolume?: Record<string, number>
+  /**
+   * Extra linux-wallpaperengine arguments appended verbatim to the launch, e.g. `--render-debug skip-effect=726`.
+   * Launch-time only, so changing this always restarts the process.
+   */
+  customArgs?: string
 }
 
 export interface LweSceneObject {
@@ -100,6 +112,17 @@ export interface LweSceneObject {
   type: 'image' | 'particle' | 'text' | 'sound' | 'unknown'
 }
 
+/** One effect (bloom, blur, glow, etc) attached to an object, as reported by --list-effects */
+export interface LweSceneEffect {
+  id: string
+  /** Effect's name for the editor, e.g. "Bloom" - not necessarily unique across objects */
+  name: string
+  objectId: string
+  objectName: string
+  /** The effect asset's own category, e.g. "Blur/Sharpen" - empty if the wallpaper doesn't set one */
+  group: string
+}
+
 /** One audio-reactive script property on a scene object, as reported by --list-audio-objects */
 export interface LweAudioObject {
   objectId: string
@@ -226,7 +249,7 @@ export interface BackupProgressEvent {
   bytesCopied: number
   bytesTotal: number
   percentage: number
-  status: 'copying' | 'completed' | 'error'
+  status: 'copying' | 'verifying' | 'completed' | 'error'
   message?: string
 }