Parcourir la source

added plenty of fixes

UwU il y a 4 mois
Parent
commit
6f810ef875

+ 251 - 0
package-lock.json

@@ -12,6 +12,7 @@
         "@tanstack/react-query": "^5.59.0",
         "clsx": "^2.1.1",
         "electron-store": "^10.0.0",
+        "koffi": "^3.0.2",
         "lucide-react": "^0.460.0",
         "react": "^18.3.1",
         "react-dom": "^18.3.1",
@@ -1432,6 +1433,230 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@koromix/koffi-darwin-arm64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.0.2.tgz",
+      "integrity": "sha512-OaL5EViEvsdbc8Ut90zY44AKF3mq6JBqkK/xLkGaSXcLsOJUsD+XaPAZb/WOWcKVg7On31wP+4hB7MwHQPdiUg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-darwin-x64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.0.2.tgz",
+      "integrity": "sha512-ZkBKudZkIOy5yVcp8cUTFCrE5DSgxkcH26mUxV6m8JgUtvtps4iMIGAAZLSq5aouvQQKhbKpXtZ3YdzpcytM+g==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-freebsd-arm64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.0.2.tgz",
+      "integrity": "sha512-eVHMjYL6yMc7GOdH0juDVEYFU4D9Az6cqyI7+ytWwovwAjHNtf96S17Ag/aI2qs7WVCoHceKDdMUATzs5grvMw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-freebsd-ia32": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.0.2.tgz",
+      "integrity": "sha512-qiSkR5fF/oa8MIukjsXMmFPC/FWGfUtw3ee+jgURg0R+Tuhlvqlfh50W4rQ0Y0faITJcF+3wmCI6WSt7p4iCkA==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-freebsd-x64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.0.2.tgz",
+      "integrity": "sha512-RDqoD8tBiRoe1C/lZEdMWETKk2dTR298XgJ9PBXbw2ETMVpIX+ci++X+j+4e7J3kVldxHLdFCykNei6d1wmhTA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-linux-arm64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.0.2.tgz",
+      "integrity": "sha512-zIm5NlzFuxt4f6mDjwTDgfzu8oZngcALTmOkWbp7HSeWdjQeiy8JRaSOEpfzlIlqWoPxhRUX5Yniqkt8564VnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-linux-ia32": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.0.2.tgz",
+      "integrity": "sha512-Le3JxJswxBhO3OeeSWA400/v2sTPwTWz/IIc6ARc4SbTGUcz2jm6jXXQviW01r5OvCQ+w2Q2T4PxP+eaRa2+fQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-linux-loong64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.0.2.tgz",
+      "integrity": "sha512-TiPOFy4OX0tLhKwL6oSOhDIhwd5b48x7nxCBT8u6IW+nLXMIo2j+jla1h9niQ8HVGknGOzu48k55KVl0YMsaew==",
+      "cpu": [
+        "loong64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-linux-riscv64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.0.2.tgz",
+      "integrity": "sha512-U44szVAHW4WKTV0s6K6rjeZEURJ0CFHbZC0Ik3lpzvEidbOu8dTft7OGNr4hhBvx/XaeLMCCebPLBoYZZNW2Dg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-linux-x64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.0.2.tgz",
+      "integrity": "sha512-bm4qT+e59KWn4V/jK8uF1RV2k0/JJvmGPUj4qCJ3E9663H+6ZLqz1rpCdgXSEA95f5CNR3RWcgjQwxgKO9xFtQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-openbsd-ia32": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.0.2.tgz",
+      "integrity": "sha512-tUu2LMSyeCSe1DbJKZrjGOKRQzDhj/RAw4rVdlHXlU/B26sdyKZjEwE3GxVlYDdEBX4FiYYmoVuG3BwGWSefCw==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-openbsd-x64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.0.2.tgz",
+      "integrity": "sha512-o4QZwsKIQvlMSIJaCXsu8d2sz4bbKEJrZhmnCeT/SKEU1VEiegmDn/I6DYrg5mOMDC15GWlUP1qP6AsVlrxqXA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-win32-ia32": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.0.2.tgz",
+      "integrity": "sha512-Yz/iP2xLaq8t3TFy5+f2Oww7rBpXgZ4YRexdYffSm2EwcnDeCtPudQSjIiKyEBdvDsU2vQetTrxyqDHStXXocw==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
+    "node_modules/@koromix/koffi-win32-x64": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.0.2.tgz",
+      "integrity": "sha512-zozoHzvSi61jch4sKqFi3ATsDC7lviFthoivzPKVk4VeJvFeSdqueKoYGHNXEzfhIXn9Y5K85l9a59nwfutkUA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      }
+    },
     "node_modules/@malept/cross-spawn-promise": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
@@ -5228,6 +5453,32 @@
         "json-buffer": "3.0.1"
       }
     },
+    "node_modules/koffi": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.0.2.tgz",
+      "integrity": "sha512-YS4sGzlVMhFNNkKbkB3tcLKJxH9WGP3jHTxi4Eyx+ieMRKVz4K2i+6rebYr9ngHN36jhIW6YXnxkzBeOQ51lsw==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://liberapay.com/Koromix"
+      },
+      "optionalDependencies": {
+        "@koromix/koffi-darwin-arm64": "3.0.2",
+        "@koromix/koffi-darwin-x64": "3.0.2",
+        "@koromix/koffi-freebsd-arm64": "3.0.2",
+        "@koromix/koffi-freebsd-ia32": "3.0.2",
+        "@koromix/koffi-freebsd-x64": "3.0.2",
+        "@koromix/koffi-linux-arm64": "3.0.2",
+        "@koromix/koffi-linux-ia32": "3.0.2",
+        "@koromix/koffi-linux-loong64": "3.0.2",
+        "@koromix/koffi-linux-riscv64": "3.0.2",
+        "@koromix/koffi-linux-x64": "3.0.2",
+        "@koromix/koffi-openbsd-ia32": "3.0.2",
+        "@koromix/koffi-openbsd-x64": "3.0.2",
+        "@koromix/koffi-win32-ia32": "3.0.2",
+        "@koromix/koffi-win32-x64": "3.0.2"
+      }
+    },
     "node_modules/lazy-val": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",

+ 7 - 2
package.json

@@ -20,6 +20,7 @@
     "@tanstack/react-query": "^5.59.0",
     "clsx": "^2.1.1",
     "electron-store": "^10.0.0",
+    "koffi": "^3.0.2",
     "lucide-react": "^0.460.0",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
@@ -52,11 +53,15 @@
       "target": [
         {
           "target": "AppImage",
-          "arch": ["x64"]
+          "arch": [
+            "x64"
+          ]
         },
         {
           "target": "deb",
-          "arch": ["x64"]
+          "arch": [
+            "x64"
+          ]
         }
       ],
       "category": "Utility"

+ 18 - 2
src/main/ipc/steam.handlers.ts

@@ -1,6 +1,8 @@
 import { ipcMain, BrowserWindow } from 'electron'
+import * as fs from 'fs'
 import { IpcChannels } from '@shared/ipc-channels'
 import * as steam from '../services/steam.service'
+import * as library from '../services/library.service'
 
 export function registerSteamHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.STEAM_IS_RUNNING, () => {
@@ -31,15 +33,29 @@ export function registerSteamHandlers(win: BrowserWindow): void {
   })
 
   ipcMain.handle(IpcChannels.STEAM_UNSUBSCRIBE, async (_e, itemId: string) => {
+    const wallpaper = library.getWallpaper(itemId)
     await steam.unsubscribeFromItem(BigInt(itemId))
+    if (wallpaper?.localPath) {
+      try { fs.rmSync(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
+    }
+    library.deleteWallpaper(itemId)
     return { ok: true }
   })
 
-  ipcMain.handle(IpcChannels.STEAM_VOTE, async (_e, itemId: string, voteUp: boolean) => {
-    await steam.voteItem(BigInt(itemId), voteUp)
+  ipcMain.handle(IpcChannels.STEAM_VOTE, (_e, itemId: string, voteUp: boolean) => {
+    steam.voteOnItem(BigInt(itemId), voteUp)
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.STEAM_OPEN_WORKSHOP, (_e, itemId: string) => {
+    steam.openWorkshopItemOverlay(BigInt(itemId))
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.STEAM_GET_VOTED_IDS, async () => {
+    return steam.getVotedUpItemIds()
+  })
+
   ipcMain.handle(IpcChannels.STEAM_GET_SUBSCRIBED, () => {
     return steam.getSubscribedItems()
   })

+ 64 - 12
src/main/services/library.service.ts

@@ -168,6 +168,21 @@ function normalizeRating(raw?: string): ContentRating {
   }
 }
 
+function getDirSize(dirPath: string): number {
+  let total = 0
+  try {
+    for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
+      const full = path.join(dirPath, entry.name)
+      if (entry.isDirectory()) {
+        total += getDirSize(full)
+      } else if (entry.isFile()) {
+        try { total += fs.statSync(full).size } catch { /* skip */ }
+      }
+    }
+  } catch { /* skip unreadable dirs */ }
+  return total
+}
+
 function buildMeta(
   workshopId: string,
   localPath: string,
@@ -175,8 +190,9 @@ function buildMeta(
   existing?: WallpaperMeta
 ): WallpaperMeta {
   const now = Date.now()
+  const { downloading: _d, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
   return {
-    ...(existing ?? { appliedCount: 0, categories: [] }),
+    ...existingRest,
     id: workshopId,
     title: pj?.title ?? `Wallpaper ${workshopId}`,
     type: normalizeType(pj?.type),
@@ -186,6 +202,7 @@ function buildMeta(
     previewUrl: undefined,
     localPath,
     file: pj?.file,
+    fileSize: getDirSize(localPath),
     createdAt: existing?.createdAt ?? now,
     updatedAt: now,
     subscribed: true,
@@ -203,45 +220,80 @@ export function importWallpaperById(workshopId: string): WallpaperMeta | null {
   return meta
 }
 
-export function scanLibrary(): { imported: number; skipped: number } {
+export function scanLibrary(): { imported: number; skipped: number; removed: number } {
   const workshopPath = getWorkshopPath()
-  if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0 }
+  if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0, removed: 0 }
 
   const entries = fs.readdirSync(workshopPath, { withFileTypes: true })
+  const onDisk = new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
 
   // Load entire store once, mutate in memory, write once at the end
   const wallpapers = store.get('wallpapers')
   let imported = 0
   let skipped = 0
+  let removed = 0
+
+  // Remove wallpapers whose directories no longer exist on disk
+  for (const id of Object.keys(wallpapers)) {
+    if (!onDisk.has(id)) {
+      delete wallpapers[id]
+      removed++
+    }
+  }
 
   for (const entry of entries) {
     if (!entry.isDirectory()) continue
 
+    const localPath = path.join(workshopPath, entry.name)
+    const pj = readProjectJson(localPath)
+
+    if (!pj) {
+      // No project.json — directory exists but download is incomplete
+      if (!wallpapers[entry.name]) {
+        const now = Date.now()
+        wallpapers[entry.name] = {
+          id: entry.name,
+          title: `Wallpaper ${entry.name}`,
+          type: 'scene',
+          contentRating: 'everyone',
+          localPath,
+          createdAt: now,
+          updatedAt: now,
+          subscribed: true,
+          appliedCount: 0,
+          source: 'workshop',
+          tags: [],
+          categories: [],
+          downloading: true
+        }
+        imported++
+      } else {
+        skipped++
+      }
+      continue
+    }
+
     // Skip already-imported entries that have all required fields (cache hit)
     const existing = wallpapers[entry.name]
-    if (existing?.previewLocal !== undefined && existing?.contentRating !== undefined) {
+    if (!existing?.downloading && existing?.previewLocal !== undefined && existing?.contentRating !== undefined && existing?.fileSize !== undefined) {
       skipped++
       continue
     }
 
-    const localPath = path.join(workshopPath, entry.name)
-    const pj = readProjectJson(localPath)
-    if (!pj) { skipped++; continue }
-
-    wallpapers[entry.name] = buildMeta(entry.name, localPath, pj)
+    wallpapers[entry.name] = buildMeta(entry.name, localPath, pj, existing ?? undefined)
     imported++
   }
 
-  if (imported > 0) {
+  if (imported > 0 || removed > 0) {
     store.set('wallpapers', wallpapers) // single write
   }
 
-  console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped`)
+  console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped, ${removed} removed`)
 
   // Auto-cleanup folder items that reference non-existent wallpapers
   cleanupFolders()
 
-  return { imported, skipped }
+  return { imported, skipped, removed }
 }
 
 // ── Folder CRUD ──

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

@@ -564,7 +564,11 @@ export function launchLweAsync(
       })
     }
     if (child.stderr) {
-      child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
+      child.stderr.on('data', (chunk: Buffer) => {
+        stderrChunks.push(chunk)
+        const msg = chunk.toString('utf8').trim()
+        if (msg) console.log('[LWE stderr]', msg)
+      })
     }
 
     let settled = false

+ 66 - 2
src/main/services/steam.service.ts

@@ -1,3 +1,4 @@
+import * as path from 'path'
 import type { DownloadProgressEvent } from '@shared/types'
 
 // steamworks.js must be required at runtime (native module)
@@ -35,8 +36,71 @@ export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
   await getClient().workshop.unsubscribe(itemId)
 }
 
-export async function voteItem(itemId: bigint, voteUp: boolean): Promise<void> {
-  await getClient().workshop.setUserItemVote(itemId, voteUp)
+// ISteamUGC::SetUserItemVote is not bound in steamworks.js, so we call the
+// flat C API directly via koffi FFI. libsteam_api.so is already loaded by
+// steamworks.js, so dlopen just returns the existing handle.
+// 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'
+)
+const _steamLib = koffi.load(_libPath)
+const _UGCType = koffi.opaque('ISteamUGC')
+const _UGCPtr = koffi.pointer(_UGCType)
+const _getSteamUGC = _steamLib.func('SteamAPI_SteamUGC_v020', _UGCPtr, [])
+const _setUserItemVote = _steamLib.func(
+  'SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [_UGCPtr, 'uint64', 'bool']
+)
+// Resolved lazily after SteamAPI_Init (called inside steamworks.init)
+let _ugcPtr: unknown = null
+function getUGCPtr(): unknown {
+  if (!_ugcPtr) _ugcPtr = _getSteamUGC()
+  return _ugcPtr
+}
+
+export function voteOnItem(itemId: bigint, voteUp: boolean): void {
+  const handle = _setUserItemVote(getUGCPtr(), itemId, voteUp)
+  // k_uAPICallInvalid = 0 means the call failed immediately
+  if (handle === BigInt(0)) throw new Error('SetUserItemVote failed')
+  invalidateVoteCache()
+}
+
+export function openWorkshopItemOverlay(itemId: bigint): void {
+  getClient().overlay.activateToWebPage(
+    `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
+  )
+}
+
+// steamworks.js does not expose SetUserItemVote, so we fetch via getUserItems(VotedUp).
+// Results are cached for the process lifetime; call invalidateVoteCache() after a vote.
+let _votedUpCache: Set<string> | null = null
+
+export async function getVotedUpItemIds(): Promise<string[]> {
+  if (_votedUpCache) return [..._votedUpCache]
+  const c = getClient()
+  const accountId = c.localplayer.getSteamId().accountId
+  const ids = new Set<string>()
+  let page = 1
+  while (true) {
+    const r = await c.workshop.getUserItems(
+      page, accountId,
+      2 /* VotedUp */, 0 /* Items */,
+      1 /* CreationOrderDesc */,
+      { consumer: 431960 }
+    )
+    for (const item of r.items) {
+      if (item) ids.add(item.publishedFileId.toString())
+    }
+    if (r.returnedResults === 0 || ids.size >= r.totalResults) break
+    page++
+  }
+  _votedUpCache = ids
+  return [...ids]
+}
+
+export function invalidateVoteCache(): void {
+  _votedUpCache = null
 }
 
 export function getSubscribedItems(): string[] {

+ 6 - 2
src/preload/index.ts

@@ -36,7 +36,11 @@ const api = {
     itemState: (itemId: string): Promise<number> =>
       ipcRenderer.invoke(IpcChannels.STEAM_ITEM_STATE, itemId),
     vote: (itemId: string, voteUp: boolean): Promise<{ ok: boolean }> =>
-      ipcRenderer.invoke(IpcChannels.STEAM_VOTE, itemId, voteUp)
+      ipcRenderer.invoke(IpcChannels.STEAM_VOTE, itemId, voteUp),
+    openWorkshopItem: (itemId: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_OPEN_WORKSHOP, itemId),
+    getVotedIds: (): Promise<string[]> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_GET_VOTED_IDS)
   },
 
   library: {
@@ -55,7 +59,7 @@ const api = {
     getTags: (): Promise<string[]> => ipcRenderer.invoke(IpcChannels.LIBRARY_GET_TAGS),
     search: (query: string): Promise<WallpaperMeta[]> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_SEARCH, query),
-    scan: (): Promise<{ imported: number; skipped: number }> =>
+    scan: (): Promise<{ imported: number; skipped: number; removed: number }> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_SCAN),
     distinctTags: (): Promise<string[]> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_DISTINCT_TAGS)

+ 23 - 4
src/renderer/src/components/library/LibraryView.tsx

@@ -235,6 +235,7 @@ export default function LibraryView() {
   const [scanResult, setScanResult] = useState<{
     imported: number
     skipped: number
+    removed: number
   } | null>(null)
 
   // Pagination
@@ -401,6 +402,13 @@ export default function LibraryView() {
     queryFn: () => window.electronAPI.lwe.status()
   })
 
+  const { data: votedIds } = useQuery({
+    queryKey: ['steam-voted-ids'],
+    queryFn: () => window.electronAPI.steam.getVotedIds(),
+    staleTime: Infinity
+  })
+  const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
+
   async function handleScan() {
     setScanning(true)
     setScanResult(null)
@@ -668,7 +676,6 @@ export default function LibraryView() {
     if (!ctxMenu) return
     for (const id of ctxMenu.ids) {
       try { await window.electronAPI.steam.unsubscribe(id) } catch {}
-      try { await window.electronAPI.library.delete(id) } catch {}
     }
     closeCtxMenu()
     queryClient.invalidateQueries({ queryKey: ['library'] })
@@ -680,6 +687,7 @@ export default function LibraryView() {
     for (const id of ctxMenu.ids) {
       try { await window.electronAPI.steam.vote(id, up) } catch {}
     }
+    queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
     closeCtxMenu()
   }
 
@@ -761,7 +769,7 @@ export default function LibraryView() {
         <select
           value={sortBy ?? 'updatedAt'}
           onChange={(e) => setSortBy(e.target.value as LibraryFilters['sortBy'])}
-          className="rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+          className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
         >
           <option value="title">Name</option>
           <option value="updatedAt">Date Updated</option>
@@ -780,7 +788,7 @@ export default function LibraryView() {
         <select
           value={pageSize}
           onChange={(e) => setPageSize(Number(e.target.value))}
-          className="rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+          className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
         >
           <option value={50}>50 / page</option>
           <option value={100}>100 / page</option>
@@ -857,7 +865,7 @@ export default function LibraryView() {
 
       {scanResult && (
         <div className="border-b border-white/5 px-4 py-2 text-xs text-gray-400">
-          Scan complete — {scanResult.imported} wallpapers imported
+          Scan complete — {scanResult.imported} imported{scanResult.removed > 0 ? `, ${scanResult.removed} removed` : ''}
         </div>
       )}
 
@@ -1057,9 +1065,20 @@ export default function LibraryView() {
                 wallpaper={wallpaper}
                 selected={selectedIds.has(wallpaper.id)}
                 lweInstalled={lweStatus?.installed ?? false}
+                isLiked={votedSet.has(wallpaper.id)}
                 onApplied={() =>
                   queryClient.invalidateQueries({ queryKey: ['library'] })
                 }
+                onLiked={() => {
+                  queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+                    old ? [...old, wallpaper.id] : [wallpaper.id]
+                  )
+                  queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
+                }}
+                onUnsubscribed={() => {
+                  queryClient.invalidateQueries({ queryKey: ['library'] })
+                  refetchFolders()
+                }}
                 onSelect={(e) => handleCardSelect(wallpaper.id, e)}
                 onContextMenu={(e) => openWallpaperCtxMenu(e, wallpaper.id)}
               />

+ 101 - 5
src/renderer/src/components/library/WallpaperCard.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react'
-import { Play, Loader2, Check } from 'lucide-react'
+import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download } from 'lucide-react'
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
 
@@ -7,7 +7,10 @@ interface WallpaperCardProps {
   wallpaper: WallpaperMeta
   selected?: boolean
   lweInstalled?: boolean
+  isLiked?: boolean
   onApplied?: () => void
+  onLiked?: () => void
+  onUnsubscribed?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
 }
@@ -16,11 +19,16 @@ export default function WallpaperCard({
   wallpaper,
   selected,
   lweInstalled,
+  isLiked = false,
   onApplied,
+  onLiked,
+  onUnsubscribed,
   onSelect,
   onContextMenu
 }: WallpaperCardProps) {
   const [isApplying, setIsApplying] = useState(false)
+  const [isLiking, setIsLiking] = useState(false)
+  const [unsubState, setUnsubState] = useState<'idle' | 'confirm' | 'pending'>('idle')
   const [error, setError] = useState<string | null>(null)
 
   async function handleApply(e: React.MouseEvent) {
@@ -37,6 +45,34 @@ export default function WallpaperCard({
     }
   }
 
+  async function handleLike(e: React.MouseEvent) {
+    e.stopPropagation()
+    if (isLiked || isLiking) return
+    setIsLiking(true)
+    try {
+      await window.electronAPI.steam.vote(wallpaper.id, true)
+      onLiked?.()
+    } catch (err) {
+      setError((err as Error).message)
+    } finally {
+      setIsLiking(false)
+    }
+  }
+
+  async function handleUnsubscribeClick(e: React.MouseEvent) {
+    e.stopPropagation()
+    if (unsubState === 'idle') { setUnsubState('confirm'); return }
+    if (unsubState !== 'confirm') return
+    setUnsubState('pending')
+    try {
+      await window.electronAPI.steam.unsubscribe(wallpaper.id)
+      onUnsubscribed?.()
+    } catch (err) {
+      setError((err as Error).message)
+      setUnsubState('idle')
+    }
+  }
+
   const previewSrc = wallpaper.previewLocal
     ? `wallpaper://${wallpaper.previewLocal}`
     : wallpaper.previewUrl
@@ -71,7 +107,7 @@ export default function WallpaperCard({
         <Check size={12} />
       </div>
 
-      <div className="aspect-video overflow-hidden bg-[#111]">
+      <div className="aspect-video overflow-hidden bg-[#111] relative">
         {previewSrc ? (
           <img
             src={previewSrc}
@@ -83,6 +119,12 @@ export default function WallpaperCard({
         ) : (
           <div className="flex h-full items-center justify-center text-gray-600">No preview</div>
         )}
+        {wallpaper.downloading && (
+          <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/70">
+            <Download size={20} className="text-indigo-400 animate-bounce" />
+            <span className="text-xs text-gray-300">Downloading…</span>
+          </div>
+        )}
       </div>
 
       <div className="p-3">
@@ -99,17 +141,71 @@ export default function WallpaperCard({
           </div>
         )}
         {error && <p className="mt-1 text-xs text-red-400">{error}</p>}
+        <div className="mt-2 flex items-center gap-2">
+          <button
+            onClick={handleLike}
+            disabled={isLiked || isLiking}
+            title={isLiked ? 'Already liked on Steam' : 'Like on Steam'}
+            className={clsx(
+              'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors disabled:cursor-default',
+              isLiked
+                ? 'bg-green-600/30 text-green-300'
+                : 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-gray-200'
+            )}
+          >
+            {isLiking ? <Loader2 size={11} className="animate-spin" /> : <ThumbsUp size={11} />}
+            {isLiked ? 'Liked' : 'Like'}
+          </button>
+          {unsubState === 'confirm' ? (
+            <div className="flex items-center gap-1">
+              <span className="text-xs text-gray-400">Sure?</span>
+              <button
+                onClick={handleUnsubscribeClick}
+                className="rounded bg-red-600/80 px-2 py-1 text-xs text-white hover:bg-red-600"
+              >
+                Yes
+              </button>
+              <button
+                onClick={(e) => { e.stopPropagation(); setUnsubState('idle') }}
+                className="rounded bg-white/5 px-2 py-1 text-xs text-gray-400 hover:bg-white/10"
+              >
+                No
+              </button>
+            </div>
+          ) : (
+            <button
+              onClick={handleUnsubscribeClick}
+              disabled={unsubState === 'pending'}
+              title="Unsubscribe and delete files"
+              className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-red-500/20 hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
+            >
+              {unsubState === 'pending' ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />}
+              Unsub
+            </button>
+          )}
+          <button
+            onClick={(e) => {
+              e.stopPropagation()
+              window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${wallpaper.id}`)
+            }}
+            title="Open in Steam Workshop"
+            className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
+          >
+            <ExternalLink size={11} />
+            Steam
+          </button>
+        </div>
       </div>
 
       {/* Apply button */}
       <div className="absolute right-2 top-2">
         <button
           onClick={handleApply}
-          disabled={isApplying || !lweInstalled}
-          title={!lweInstalled ? 'Install linux-wallpaperengine in Settings first' : 'Play wallpaper'}
+          disabled={isApplying || !lweInstalled || wallpaper.downloading}
+          title={wallpaper.downloading ? 'Wallpaper is still downloading' : !lweInstalled ? 'Install linux-wallpaperengine in Settings first' : 'Play wallpaper'}
           className={clsx(
             'flex h-7 w-7 items-center justify-center rounded-full transition-opacity disabled:cursor-not-allowed',
-            !lweInstalled
+            !lweInstalled || wallpaper.downloading
               ? 'bg-black/60 text-gray-500 opacity-0 group-hover:opacity-100'
               : 'bg-black/60 text-white opacity-0 hover:bg-indigo-600 group-hover:opacity-100'
           )}

+ 2 - 2
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -354,7 +354,7 @@ export default function WorkshopBrowser() {
         <select
           value={queryType}
           onChange={(e) => setQueryType(e.target.value as WorkshopQueryType)}
-          className="rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+          className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
         >
           <option value="RankedByVote">Top Rated</option>
           <option value="RankedByPublicationDate">Newest</option>
@@ -584,7 +584,7 @@ export default function WorkshopBrowser() {
                 <select
                   value={pageSize}
                   onChange={(e) => setPageSize(Number(e.target.value))}
-                  className="rounded bg-white/5 px-2 py-1 text-gray-300 outline-none"
+                  className="rounded bg-[#1a1a1a] px-2 py-1 text-gray-300 outline-none [&>option]:bg-[#1a1a1a] [&>option]:text-gray-300"
                 >
                   <option value={50}>50</option>
                   <option value={100}>100</option>

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

@@ -12,6 +12,8 @@ export const IpcChannels = {
   STEAM_INSTALL_INFO: 'steam:install-info',
   STEAM_IS_RUNNING: 'steam:is-running',
   STEAM_VOTE: 'steam:vote',
+  STEAM_OPEN_WORKSHOP: 'steam:open-workshop',
+  STEAM_GET_VOTED_IDS: 'steam:get-voted-ids',
 
   // Library management
   LIBRARY_GET_ALL: 'library:get-all',

+ 1 - 0
src/shared/types.ts

@@ -42,6 +42,7 @@ export interface WallpaperMeta {
   source: 'workshop' | 'local'
   tags: string[]
   categories: string[]
+  downloading?: boolean
 }
 
 export interface WorkshopItem {