Explorar o código

major refactor

UwU hai 2 meses
pai
achega
e99f54a532

+ 1 - 0
.gitignore

@@ -3,4 +3,5 @@ out/
 dist/
 .env
 *.db
+*.tsbuildinfo
 preview-cache/

+ 30 - 0
README.md

@@ -0,0 +1,30 @@
+# we_manager
+
+A Wallpaper Engine manager for Linux. Browses the Steam Workshop through
+steamworks.js, keeps a local library of subscribed wallpapers with folders and
+tags, and plays animated wallpapers on the desktop via
+[linux-wallpaperengine](https://github.com/Almamu/linux-wallpaperengine).
+
+Steam must be running and Wallpaper Engine (app id 431960) must be in the
+library for workshop browsing and subscriptions to work. Without Steam the app
+still works as a local library manager.
+
+## Running
+
+```
+npm install
+npm run dev      # dev mode with hot reload
+npm run build    # compile to out/
+npm run dist     # package with electron-builder
+```
+
+## Notes
+
+- Settings can build and install linux-wallpaperengine from source (needs
+  sudo for the package manager and `make install`).
+- The optional desktop icons overlay
+  (`src/main/services/desktop-icons-overlay.py`) draws KDE Folder View icons
+  on a transparent layer-shell surface above the wallpaper. It needs python3,
+  GTK4, gtk4-layer-shell and pycairo, and only works on Wayland.
+- `steam_appid.txt` is required by the Steamworks SDK and must stay next to
+  the app.

+ 2 - 1
electron.vite.config.ts

@@ -1,6 +1,7 @@
 import { resolve } from 'path'
 import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
 import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
 
 export default defineConfig({
   main: {
@@ -35,6 +36,6 @@ export default defineConfig({
         '@shared': resolve('src/shared')
       }
     },
-    plugins: [react()]
+    plugins: [react(), tailwindcss()]
   }
 })

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 215 - 422
package-lock.json


+ 20 - 28
package.json

@@ -3,45 +3,37 @@
   "version": "0.1.0",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
-  "author": {
-    "name": "WE Manager",
-    "email": "developer@example.com"
-  },
+  "author": "UwU",
   "main": "out/main/index.js",
   "scripts": {
     "dev": "electron-vite dev",
     "build": "electron-vite build",
     "preview": "electron-vite preview",
     "dist": "npm run build && electron-builder",
-    "dist:linux": "npm run build && electron-builder --linux",
-    "postinstall": "echo 'Dependencies installed'"
+    "dist:linux": "npm run build && electron-builder --linux"
   },
   "dependencies": {
-    "@tanstack/react-query": "^5.59.0",
+    "@tanstack/react-query": "^5.101.2",
     "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",
-    "steamworks.js": "^0.4.0",
-    "zustand": "^4.5.2"
+    "electron-store": "^11.0.2",
+    "koffi": "^3.1.1",
+    "lucide-react": "^1.24.0",
+    "react": "^19.2.7",
+    "react-dom": "^19.2.7",
+    "steamworks.js": "^0.4.0"
   },
   "devDependencies": {
-    "@electron-toolkit/preload": "^3.0.0",
-    "@electron-toolkit/utils": "^3.0.0",
-    "@types/node": "^22.0.0",
-    "@types/react": "^18.3.1",
-    "@types/react-dom": "^18.3.0",
-    "@vitejs/plugin-react": "^4.3.1",
-    "autoprefixer": "^10.4.20",
-    "electron": "^33.0.0",
-    "electron-builder": "^26.7.0",
-    "electron-vite": "^2.3.0",
-    "postcss": "^8.4.47",
-    "tailwindcss": "^3.4.14",
-    "typescript": "^5.6.3",
-    "vite": "^5.4.10"
+    "@tailwindcss/vite": "^4.3.2",
+    "@types/node": "^26.1.1",
+    "@types/react": "^19.2.17",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^5.2.0",
+    "electron": "^43.1.0",
+    "electron-builder": "^26.15.3",
+    "electron-vite": "^5.0.0",
+    "tailwindcss": "^4.3.2",
+    "typescript": "^7.0.2",
+    "vite": "^7.3.6"
   },
   "build": {
     "appId": "com.we-manager.app",

+ 0 - 6
postcss.config.js

@@ -1,6 +0,0 @@
-export default {
-  plugins: {
-    tailwindcss: {},
-    autoprefixer: {}
-  }
-}

+ 7 - 7
src/main/index.ts

@@ -1,7 +1,8 @@
-import { app, BrowserWindow, protocol, shell } from 'electron'
+import { app, BrowserWindow, net, protocol, shell } from 'electron'
 import path from 'path'
+import { pathToFileURL } from 'url'
 import { initSteam } from './services/steam.service'
-import { initDatabase } from './services/library.service'
+import { initLibrary } from './services/library.service'
 import { startWatcher } from './services/watcher.service'
 import { registerAllHandlers } from './ipc'
 import { initDesktopIcons, cleanupDesktopIcons } from './services/desktop-icons.service'
@@ -38,18 +39,17 @@ function createWindow(): BrowserWindow {
 
 app.whenReady().then(() => {
   // Serve local wallpaper files via custom protocol
-  protocol.registerFileProtocol('wallpaper', (request, callback) => {
+  protocol.handle('wallpaper', (request) => {
     const filePath = decodeURIComponent(request.url.replace('wallpaper://', ''))
-    callback({ path: filePath })
+    return net.fetch(pathToFileURL(filePath).toString())
   })
 
-  // Initialize services
   const steamOk = initSteam()
   if (!steamOk) {
-    console.warn('[App] Steam unavailable — subscription features will be disabled')
+    console.warn('[App] Steam unavailable, subscription features disabled')
   }
 
-  initDatabase()
+  initLibrary()
 
   const win = createWindow()
   registerAllHandlers(win)

+ 0 - 1
src/main/ipc/library.handlers.ts

@@ -3,7 +3,6 @@ import { IpcChannels } from '@shared/ipc-channels'
 import * as library from '../services/library.service'
 import type { LibraryFilters, WallpaperMeta } from '@shared/types'
 
-
 export function registerLibraryHandlers(): void {
   ipcMain.handle(IpcChannels.LIBRARY_GET_ALL, (_e, filters?: LibraryFilters) => {
     return library.getAllWallpapers(filters)

+ 3 - 3
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, launchLwe, stopLwe, isLweRunning } from '../services/lwe.service'
+import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, stopLwe, isLweRunning } from '../services/lwe.service'
 import { invalidateEnvCache } from '../services/wallpaper.service'
 
 export function registerLweHandlers(win: BrowserWindow): void {
@@ -29,8 +29,8 @@ export function registerLweHandlers(win: BrowserWindow): void {
     return result
   })
 
-  ipcMain.handle(IpcChannels.LWE_LAUNCH, (_e, wallpaperPath: string, options?: { screenRoot?: string; fps?: number }) => {
-    launchLwe(wallpaperPath, options)
+  ipcMain.handle(IpcChannels.LWE_LAUNCH, async (_e, wallpaperPath: string, options?: { screenRoot?: string; fps?: number }) => {
+    await launchLweAsync(wallpaperPath, options)
     return { ok: true, running: isLweRunning() }
   })
 

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

@@ -12,8 +12,12 @@ export function registerSteamHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.STEAM_SUBSCRIBE, async (_e, itemId: string) => {
     await steam.subscribeToItem(BigInt(itemId))
 
-    // Poll download progress (1s is plenty for UI updates)
+    // Poll download progress until Steam reports the download is gone
     const pollInterval = setInterval(() => {
+      if (win.isDestroyed()) {
+        clearInterval(pollInterval)
+        return
+      }
       const info = steam.getDownloadInfo(BigInt(itemId))
       if (!info) {
         clearInterval(pollInterval)

+ 14 - 30
src/main/ipc/wallpaper.handlers.ts

@@ -7,6 +7,8 @@ import { getDefaultFps } from '../services/config.service'
 import type { ApplyWallpaperOptions } from '@shared/types'
 import Store from 'electron-store'
 import * as os from 'os'
+import * as fs from 'fs'
+import * as path from 'path'
 
 interface StoreSchema {
   activeWallpaperId?: string
@@ -21,8 +23,7 @@ export function registerWallpaperHandlers(): void {
 
   ipcMain.handle(IpcChannels.WALLPAPER_GET_ACTIVE, () => {
     const id = store.get('activeWallpaperId')
-    if (!id) return null
-    return getWallpaper(id as string)
+    return id ? getWallpaper(id) : null
   })
 
   ipcMain.handle(IpcChannels.WALLPAPER_APPLY, async (_e, options: ApplyWallpaperOptions) => {
@@ -33,58 +34,41 @@ export function registerWallpaperHandlers(): void {
     const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
     const lwe = getLweStatus()
 
-    // Use linux-wallpaperengine for animated wallpapers on Linux if available
+    let appliedPath: string
     if (isAnimated && os.platform() === 'linux' && lwe.installed) {
       const fps = wallpaper.fpsOverride ?? getDefaultFps() ?? undefined
       await launchLweAsync(wallpaper.localPath, { fps })
-
-      store.set('activeWallpaperId', options.wallpaperId)
-      updateWallpaper(options.wallpaperId, {
-        appliedCount: (wallpaper.appliedCount ?? 0) + 1,
-        lastAppliedAt: Date.now()
-      })
-
-      return { ok: true, appliedPath: wallpaper.localPath }
+      appliedPath = wallpaper.localPath
+    } else {
+      // Static fallback: set the preview image as a plain wallpaper
+      appliedPath = findWallpaperImage(wallpaper.localPath)
+      await applyWallpaper(appliedPath, options.backend)
     }
 
-    // Static wallpaper fallback — find an image file
-    const imagePath = await findWallpaperImage(wallpaper.localPath)
-    await applyWallpaper(imagePath, options.backend)
-
     store.set('activeWallpaperId', options.wallpaperId)
     updateWallpaper(options.wallpaperId, {
       appliedCount: (wallpaper.appliedCount ?? 0) + 1,
       lastAppliedAt: Date.now()
     })
 
-    return { ok: true, appliedPath: imagePath }
+    return { ok: true, appliedPath }
   })
 }
 
-async function findWallpaperImage(dirPath: string): Promise<string> {
-  const fs = await import('fs')
-  const path = await import('path')
-
+function findWallpaperImage(dirPath: string): string {
   if (!fs.existsSync(dirPath)) {
     throw new Error(`Directory not found: ${dirPath}`)
   }
-
-  const stat = fs.statSync(dirPath)
-  if (!stat.isDirectory()) {
-    // It IS the file
+  if (!fs.statSync(dirPath).isDirectory()) {
     return dirPath
   }
 
   const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
   const files = fs.readdirSync(dirPath)
 
-  // Prefer preview.png, then any image
   const preview = files.find((f) => f.toLowerCase().startsWith('preview'))
-  if (preview) {
-    const ext = path.extname(preview).toLowerCase()
-    if (imageExtensions.includes(ext)) {
-      return path.join(dirPath, preview)
-    }
+  if (preview && imageExtensions.includes(path.extname(preview).toLowerCase())) {
+    return path.join(dirPath, preview)
   }
 
   const image = files.find((f) => imageExtensions.includes(path.extname(f).toLowerCase()))

+ 1 - 1
src/main/services/config.service.ts

@@ -73,7 +73,7 @@ export function importWEConfigFile(sourcePath: string): string {
 
   const destPath = getWeConfigPath()
   fs.writeFileSync(destPath, content, 'utf8')
-  console.log(`[Config] Imported WE config from ${sourcePath} → ${destPath}`)
+  console.log(`[Config] Imported WE config from ${sourcePath} to ${destPath}`)
   return destPath
 }
 

+ 4 - 4
src/main/services/desktop-icons-overlay.py

@@ -5,7 +5,7 @@ Desktop icons overlay for we_manager.
 Renders desktop icons on a transparent layer-shell surface (LAYER_BOTTOM)
 so they appear on top of the linux-wallpaperengine wallpaper.
 
-The overlay is purely visual — input passes through via empty wl_region
+The overlay is purely visual: input passes through via an empty wl_region
 to plasmashell's Folder View underneath, which handles clicks normally.
 
 Launch with: LD_PRELOAD=/usr/lib64/libgtk4-layer-shell.so python3 this.py [MONITOR]
@@ -17,7 +17,7 @@ import json
 import subprocess
 import configparser
 
-import cairo as _cairo  # pycairo — needed for empty input region
+import cairo as _cairo  # pycairo, needed for the empty input region
 
 import gi
 gi.require_version('Gtk4LayerShell', '1.0')
@@ -186,7 +186,7 @@ def get_mime_icon(path):
 
 # --- KDE settings auto-detection ---
 
-# Kirigami icon size table (index → pixels), matching FolderTools.js
+# Kirigami icon size table (index to pixels), matching FolderTools.js
 _ICON_SIZE_TABLE = [22, 32, 48, 64, 96, 128, 256]
 
 # CSS font-weight mapping from KDE weight values (Qt::Weight)
@@ -371,7 +371,7 @@ def main():
         Gtk4LayerShell.set_exclusive_zone(window, -1)
         Gtk4LayerShell.set_keyboard_mode(window, Gtk4LayerShell.KeyboardMode.NONE)
 
-        # CSS — use auto-detected font settings
+        # CSS built from the auto-detected font settings
         font_style = 'italic' if _kde['font_italic'] else 'normal'
         font_weight = _kde['font_weight']
         css = Gtk.CssProvider()

+ 6 - 9
src/main/services/desktop-icons.service.ts

@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from 'child_process'
 import * as path from 'path'
 import * as fs from 'fs'
 import Store from 'electron-store'
-import { getConnectedScreens } from '../utils/platform'
+import { getConnectedScreens, getWaylandDisplay, getXdgRuntimeDir } from '../utils/platform'
 
 interface DesktopIconsStoreSchema {
   desktopIconsEnabled: boolean
@@ -40,25 +40,22 @@ function findLayerShellLib(): string {
   return candidates.find((p) => fs.existsSync(p)) ?? candidates[0]
 }
 
-/** Build env vars for the overlay — Electron runs under XWayland so we must pass Wayland env explicitly. */
+/** Env vars for the overlay; Electron runs under XWayland so the Wayland env must be passed explicitly. */
 function buildOverlayEnvVars(): string[] {
   const vars: string[] = []
 
   vars.push(`LD_PRELOAD=${findLayerShellLib()}`)
 
-  // Force GTK4 to use Wayland backend (otherwise it falls back to X11 where layer-shell doesn't work)
+  // Force GTK4 onto the Wayland backend (layer-shell does not work under X11)
   vars.push('GDK_BACKEND=wayland')
 
-  // Wayland display — Electron often lacks this since it runs under XWayland
-  const waylandDisplay = process.env.WAYLAND_DISPLAY
-    || (process.env.XDG_SESSION_TYPE === 'wayland' ? 'wayland-0' : undefined)
+  const waylandDisplay = getWaylandDisplay()
   if (waylandDisplay) vars.push(`WAYLAND_DISPLAY=${waylandDisplay}`)
 
   // XDG_RUNTIME_DIR is needed for the Wayland socket
-  const xdgRuntime = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`
-  vars.push(`XDG_RUNTIME_DIR=${xdgRuntime}`)
+  vars.push(`XDG_RUNTIME_DIR=${getXdgRuntimeDir()}`)
 
-  // Pass DISPLAY through for XWayland fallback
+  // DISPLAY for the XWayland fallback
   if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
 
   return vars

+ 6 - 6
src/main/services/library.service.ts

@@ -20,7 +20,7 @@ const store = new Store<LibraryStore>({
   }
 })
 
-export function initDatabase(): void {
+export function initLibrary(): void {
   console.log('[Library] Store initialized at', store.path)
 }
 
@@ -261,7 +261,7 @@ export function scanLibrary(): { imported: number; skipped: number; removed: num
     const pj = readProjectJson(localPath)
 
     if (!pj) {
-      // No project.json — directory exists but download is incomplete
+      // No project.json yet: the directory exists but the download is incomplete
       if (!wallpapers[entry.name]) {
         const now = Date.now()
         wallpapers[entry.name] = {
@@ -298,7 +298,7 @@ export function scanLibrary(): { imported: number; skipped: number; removed: num
   }
 
   if (imported > 0 || removed > 0) {
-    store.set('wallpapers', wallpapers) // single write
+    store.set('wallpapers', wallpapers)
   }
 
   console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped, ${removed} removed`)
@@ -309,7 +309,7 @@ export function scanLibrary(): { imported: number; skipped: number; removed: num
   return { imported, skipped, removed }
 }
 
-// ── Folder CRUD ──
+// Folders
 
 export function getAllFolders(): WallpaperFolder[] {
   return store.get('folders')
@@ -390,7 +390,7 @@ export function importWEConfig(configPath: string): { folders: number; playlists
   const imported: WallpaperFolder[] = []
   const existingTitles = new Set(store.get('folders').map((f) => f.title))
 
-  // Try each profile — pick the one with the most folders (usually '~')
+  // Try each profile and pick the one with the most folders (usually '~')
   let bestProfile: { folders: any[]; playlists: any[] } = { folders: [], playlists: [] }
   for (const key of Object.keys(data)) {
     const val = data[key]
@@ -415,7 +415,7 @@ export function importWEConfig(configPath: string): { folders: number; playlists
     existingTitles.add(title)
   }
 
-  // Import playlists (items are full file paths — extract workshop ID)
+  // Import playlists (items are full file paths, so extract the workshop ID)
   for (const p of bestProfile.playlists) {
     const title = p.name ?? 'Untitled Playlist'
     if (existingTitles.has(title)) continue

+ 31 - 75
src/main/services/lwe.service.ts

@@ -1,4 +1,4 @@
-import { execFile, execFileSync, spawn, type ChildProcess } from 'child_process'
+import { execFile, spawn, type ChildProcess } from 'child_process'
 import { promisify } from 'util'
 import * as fs from 'fs'
 import * as path from 'path'
@@ -6,7 +6,14 @@ import * as os from 'os'
 import type { BrowserWindow } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
 import type { LweStatus, LweInstallProgress, LinuxDistro } from '@shared/types'
-import { isCommandAvailable, invalidateCommandCache, whichCommand, getConnectedScreens } from '../utils/platform'
+import {
+  isCommandAvailable,
+  invalidateCommandCache,
+  whichCommand,
+  getConnectedScreens,
+  getWaylandDisplay,
+  getXdgRuntimeDir
+} from '../utils/platform'
 import { getWorkshopPath } from '../utils/paths'
 
 const execFileAsync = promisify(execFile)
@@ -15,10 +22,11 @@ const LWE_BINARY = 'linux-wallpaperengine'
 const LWE_REPO = 'https://github.com/Almamu/linux-wallpaperengine.git'
 const BUILD_DIR = path.join(os.tmpdir(), 'lwe-build')
 
-/** Common paths where linux-wallpaperengine may be installed outside $PATH. */
+/** Common install locations checked before falling back to $PATH. */
 const LWE_SEARCH_PATHS = [
-  '/home/uwu/projects/private/linux-wallpaperengine/build/output/linux-wallpaperengine',
-  '/home/uwu/projects/linux-wallpaperengine/build/output/linux-wallpaperengine',
+  // local fork builds take precedence over system installs
+  path.join(os.homedir(), 'projects', 'private', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
+  path.join(os.homedir(), 'projects', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
   '/usr/local/bin/linux-wallpaperengine',
   '/usr/local/linux-wallpaperengine',
   '/usr/bin/linux-wallpaperengine',
@@ -29,31 +37,19 @@ const LWE_SEARCH_PATHS = [
 
 let activeProcess: ChildProcess | null = null
 
-/** Find the LWE binary — preferred paths first, then $PATH as fallback. */
 function findLweBinary(): string | undefined {
-  // 1. Scan preferred locations first (fork builds, custom installs)
   for (const p of LWE_SEARCH_PATHS) {
     try {
       fs.accessSync(p, fs.constants.X_OK)
       return p
-    } catch { /* not here */ }
+    } catch { /* keep looking */ }
   }
-
-  // 2. Fall back to $PATH
-  const inPath = whichCommand(LWE_BINARY)
-  if (inPath) return inPath
-
-  return undefined
+  return whichCommand(LWE_BINARY)
 }
 
 export function getLweStatus(): LweStatus {
-  // Scan preferred paths first, then $PATH
   const found = findLweBinary()
-  if (found) {
-    return { installed: true, path: found }
-  }
-
-  return { installed: false }
+  return found ? { installed: true, path: found } : { installed: false }
 }
 
 export function detectDistro(): LinuxDistro {
@@ -78,7 +74,7 @@ const DEPS_DEBIAN = [
   'libgmp-dev', 'patchelf'
 ]
 
-// Fedora/Nobara: do not add 'ffmpeg' — conflicts with ffmpeg-free; use ffmpeg-free-devel only
+// Fedora/Nobara: 'ffmpeg' conflicts with ffmpeg-free, so only ffmpeg-free-devel is listed
 const DEPS_FEDORA = [
   'gcc', 'g++', 'cmake', 'pkg-config',
   'libXrandr-devel', 'libXinerama-devel', 'libXcursor-devel', 'libXi-devel',
@@ -151,7 +147,7 @@ export async function installLweDeps(win: BrowserWindow): Promise<void> {
     })
     send({
       stage: 'done',
-      message: 'Build dependencies installed successfully!',
+      message: 'Build dependencies installed.',
       percentage: 100
     })
   } catch (err) {
@@ -201,7 +197,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
     const cmakeBuild = path.join(BUILD_DIR, 'build')
     fs.mkdirSync(cmakeBuild, { recursive: true })
 
-    // CMake configure — CEF download can take several minutes; install to /usr/local and symlink into PATH
+    // cmake configure downloads CEF on first run, which can take several minutes
     send({ stage: 'building', message: 'Running cmake (downloading CEF if needed, may take a few minutes)...', percentage: 25 })
     await execFileAsync('cmake', [
       '..',
@@ -228,7 +224,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
 
     send({ stage: 'building', message: 'Build complete.', percentage: 80 })
 
-    // Install — needs sudo via graphical prompt (binary ends up at /usr/local/linux-wallpaperengine)
+    // make install puts the binary at /usr/local/linux-wallpaperengine
     send({ stage: 'installing', message: 'Installing (sudo required)...', percentage: 85 })
 
     const installTool = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
@@ -265,7 +261,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
 
     const status = getLweStatus()
     if (status.installed) {
-      send({ stage: 'done', message: 'linux-wallpaperengine installed successfully!', percentage: 100 })
+      send({ stage: 'done', message: 'linux-wallpaperengine installed.', percentage: 100 })
     } else {
       send({
         stage: 'error',
@@ -280,34 +276,30 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
       }
     } catch { /* ignore cleanup errors */ }
 
-    // Capture both stdout and stderr (Node provides these on execFile error when encoding/maxBuffer set)
+    // execFile puts stdout/stderr on the error object when encoding/maxBuffer are set
     const e = err as Error & { stdout?: string; stderr?: string }
     const fullMsg = e.message ?? String(err)
     const combined = [e.stdout, e.stderr].filter(Boolean).join('\n')
     const lines = (combined || fullMsg).split('\n')
 
-    // 1. Real compiler/linker errors (highest priority)
     const compilerErrors = lines.filter(
       (l: string) => /:\d+:\d+: (?:fatal )?error:/i.test(l) || /^\/.*error:/i.test(l) ||
         /undefined reference/i.test(l) || /ld returned/i.test(l) ||
         /collect2: error/i.test(l)
     )
-
-    // 2. CMake / install / permission errors
     const otherErrors = lines.filter(
       (l: string) => /CMake Error/i.test(l) || /No such file or directory/i.test(l) ||
         /Permission denied/i.test(l) || /cannot create/i.test(l) ||
         /fatal:/i.test(l) || /failed to/i.test(l)
     )
 
+    // Prefer compiler/linker errors, then cmake/install errors, then the tail of the log
     let excerpt: string
     if (compilerErrors.length > 0) {
-      // Show the first few real compiler errors (most useful)
       excerpt = compilerErrors.slice(0, 10).join('\n')
     } else if (otherErrors.length > 0) {
       excerpt = otherErrors.slice(0, 8).join('\n')
     } else {
-      // Fallback: show last 30 lines (skip empty/make-summary-only lines)
       excerpt = lines.filter((l: string) => l.trim() && !/^make\[\d+\]: (Entering|Leaving)/i.test(l))
         .slice(-30).join('\n')
     }
@@ -436,24 +428,6 @@ function getLweBinaryPath(): string {
   }
 }
 
-/** Detect Wayland display from the session if Electron doesn't have it (Electron often runs under XWayland). */
-function getWaylandDisplay(): string | undefined {
-  if (process.env.WAYLAND_DISPLAY) return process.env.WAYLAND_DISPLAY
-
-  // Check XDG_SESSION_TYPE to see if we're in a Wayland session
-  if (process.env.XDG_SESSION_TYPE === 'wayland') return 'wayland-0'
-
-  // Try to detect from loginctl
-  try {
-    const sessionType = execFileSync('bash', ['-c',
-      'loginctl show-session $(loginctl 2>/dev/null | grep $USER | head -1 | awk \'{print $1}\') -p Type --value 2>/dev/null'
-    ], { encoding: 'utf8', timeout: 3000 }).trim()
-    if (sessionType === 'wayland') return 'wayland-0'
-  } catch { /* ignore */ }
-
-  return undefined
-}
-
 /** Build env vars that must be set for LWE, as KEY=VALUE strings for use with `env` command. */
 function buildLweEnvVars(): string[] {
   const vars: string[] = []
@@ -467,20 +441,18 @@ function buildLweEnvVars(): string[] {
     .find(p => fs.existsSync(p))
   if (systemEgl) vars.push(`LD_PRELOAD=${systemEgl}`)
 
-  // Wayland display — Electron often runs under XWayland and lacks WAYLAND_DISPLAY.
-  const waylandDisplay = process.env.WAYLAND_DISPLAY || getWaylandDisplay()
+  const waylandDisplay = getWaylandDisplay()
   if (waylandDisplay) vars.push(`WAYLAND_DISPLAY=${waylandDisplay}`)
 
   // XDG_SESSION_TYPE is how LWE decides between X11 and Wayland drivers
   const sessionType = process.env.XDG_SESSION_TYPE
   if (sessionType) vars.push(`XDG_SESSION_TYPE=${sessionType}`)
 
-  // Ensure DISPLAY is passed (for X11/XWayland)
+  // DISPLAY for X11/XWayland
   if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
 
-  // Ensure XDG_RUNTIME_DIR is set (needed for Wayland socket)
-  const xdgRuntime = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`
-  vars.push(`XDG_RUNTIME_DIR=${xdgRuntime}`)
+  // XDG_RUNTIME_DIR is needed for the Wayland socket
+  vars.push(`XDG_RUNTIME_DIR=${getXdgRuntimeDir()}`)
 
   return vars
 }
@@ -507,10 +479,9 @@ function buildLweArgs(
   return args
 }
 
-/** Get the control file path used for hot-reload signalling. */
+/** Control file used for hot-reload signalling. */
 function getControlFilePath(): string {
-  const xdgRuntime = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`
-  return path.join(xdgRuntime, 'lwe-control')
+  return path.join(getXdgRuntimeDir(), 'lwe-control')
 }
 
 /** Hot-reload: write new wallpaper path to control file and send SIGUSR1. */
@@ -544,8 +515,8 @@ export function launchLweAsync(
   const lweArgs = buildLweArgs(wallpaperPath, options)
   const envVars = buildLweEnvVars()
 
-  // Spawn via `env` to set env vars explicitly — matches how LWE works from the terminal.
-  // Using the env option on spawn can lose vars when combined with detached/setsid.
+  // Spawn via `env` to set env vars explicitly, matching how LWE works from the terminal.
+  // The env option on spawn can lose vars when combined with detached/setsid.
   const spawnArgs = [...envVars, binaryPath, ...lweArgs]
   console.log('[LWE] Binary:', binaryPath)
   console.log('[LWE] Args:', lweArgs.join(' '))
@@ -610,21 +581,6 @@ export function launchLweAsync(
   })
 }
 
-/** Sync launch (no wait); use launchLweAsync in handlers so startup failures are visible. */
-export function launchLwe(
-  wallpaperPath: string,
-  options: { screenRoot?: string; fps?: number } = {}
-): ChildProcess {
-  stopLwe()
-  const binaryPath = getLweBinaryPath()
-  const lweArgs = buildLweArgs(wallpaperPath, options)
-  const envVars = buildLweEnvVars()
-  activeProcess = spawn('env', [...envVars, binaryPath, ...lweArgs], { stdio: 'ignore', detached: true })
-  activeProcess.on('exit', () => { activeProcess = null })
-  activeProcess.unref()
-  return activeProcess
-}
-
 export function stopLwe(): void {
   if (activeProcess) {
     try {

+ 48 - 36
src/main/services/steam.service.ts

@@ -1,17 +1,16 @@
 import * as path from 'path'
+import { WE_APP_ID } from '@shared/constants'
 import type { DownloadProgressEvent } from '@shared/types'
 
-// steamworks.js must be required at runtime (native module)
-// eslint-disable-next-line @typescript-eslint/no-require-imports
-let steamworks: typeof import('steamworks.js') | null = null
 let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
 
 export function initSteam(): boolean {
   try {
+    // steamworks.js is a native module and must be required at runtime
     // eslint-disable-next-line @typescript-eslint/no-require-imports
-    steamworks = require('steamworks.js')
-    client = steamworks!.init(431960) // Wallpaper Engine
-    console.log('[Steam] Initialized successfully')
+    const steamworks: typeof import('steamworks.js') = require('steamworks.js')
+    client = steamworks.init(WE_APP_ID)
+    console.log('[Steam] Initialized')
     return true
   } catch (err) {
     console.warn('[Steam] Init failed (Steam may not be running):', err)
@@ -38,29 +37,40 @@ export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
 
 // 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
+// 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.
+interface UgcFfi {
+  setUserItemVote: (ugc: unknown, itemId: bigint, voteUp: boolean) => bigint
+  ugcPtr: unknown
+}
+
+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'
+    )
+    const steamLib = koffi.load(libPath)
+    const ugcPtrType = koffi.pointer(koffi.opaque('ISteamUGC'))
+    const getSteamUGC = steamLib.func('SteamAPI_SteamUGC_v020', ugcPtrType, [])
+    ugcFfi = {
+      setUserItemVote: steamLib.func(
+        'SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [ugcPtrType, 'uint64', 'bool']
+      ),
+      ugcPtr: getSteamUGC()
+    }
+  }
+  return ugcFfi
 }
 
 export function voteOnItem(itemId: bigint, voteUp: boolean): void {
-  const handle = _setUserItemVote(getUGCPtr(), itemId, voteUp)
+  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()
@@ -72,12 +82,12 @@ export function openWorkshopItemOverlay(itemId: bigint): void {
   )
 }
 
-// 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
+// Fetched via getUserItems(VotedUp) and cached for the process lifetime;
+// invalidateVoteCache() is called after each vote.
+let votedUpCache: Set<string> | null = null
 
 export async function getVotedUpItemIds(): Promise<string[]> {
-  if (_votedUpCache) return [..._votedUpCache]
+  if (votedUpCache) return [...votedUpCache]
   const c = getClient()
   const accountId = c.localplayer.getSteamId().accountId
   const ids = new Set<string>()
@@ -87,7 +97,7 @@ export async function getVotedUpItemIds(): Promise<string[]> {
       page, accountId,
       2 /* VotedUp */, 0 /* Items */,
       1 /* CreationOrderDesc */,
-      { consumer: 431960 }
+      { consumer: WE_APP_ID }
     )
     for (const item of r.items) {
       if (item) ids.add(item.publishedFileId.toString())
@@ -95,12 +105,12 @@ export async function getVotedUpItemIds(): Promise<string[]> {
     if (r.returnedResults === 0 || ids.size >= r.totalResults) break
     page++
   }
-  _votedUpCache = ids
+  votedUpCache = ids
   return [...ids]
 }
 
 export function invalidateVoteCache(): void {
-  _votedUpCache = null
+  votedUpCache = null
 }
 
 export function getSubscribedItems(): string[] {
@@ -113,8 +123,8 @@ export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
   try {
     const info = getClient().workshop.downloadInfo(itemId)
     if (!info) return null
-    const bytesTotal = Number(info.bytesTotal)
-    const bytesDownloaded = Number(info.bytesDownloaded)
+    const bytesTotal = Number(info.total)
+    const bytesDownloaded = Number(info.current)
     return {
       itemId: itemId.toString(),
       bytesDownloaded,
@@ -129,7 +139,9 @@ export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
 
 export function getInstallInfo(itemId: bigint): { folder: string; sizeOnDisk: number } | null {
   try {
-    return getClient().workshop.installInfo(itemId) as { folder: string; sizeOnDisk: number }
+    const info = getClient().workshop.installInfo(itemId)
+    if (!info) return null
+    return { folder: info.folder, sizeOnDisk: Number(info.sizeOnDisk) }
   } catch {
     return null
   }

+ 17 - 51
src/main/services/wallpaper.service.ts

@@ -7,7 +7,6 @@ import { getLweStatus } from './lwe.service'
 
 const execFileAsync = promisify(execFile)
 
-// Cache environment detection
 let envCache: WallpaperEnvironment | null = null
 
 export function invalidateEnvCache(): void {
@@ -67,66 +66,33 @@ export async function applyWallpaper(imagePath: string, backend?: WallpaperBacke
 }
 
 async function applyLinux(imagePath: string, preferredBackend?: WallpaperBackend): Promise<void> {
-  const ds = detectDisplayServer()
-  const de = detectDesktopEnv()
-
   if (preferredBackend && preferredBackend !== 'auto') {
     await applyWithBackend(preferredBackend, imagePath)
     return
   }
 
-  if (de === 'gnome') {
-    await execFileAsync('gsettings', [
-      'set',
-      'org.gnome.desktop.background',
-      'picture-uri',
-      `file://${imagePath}`
-    ])
-    return
-  }
-
-  if (de === 'kde' && isCommandAvailable('qdbus')) {
-    const script = `
-      var allDesktops = desktops();
-      for (var i = 0; i < allDesktops.length; i++) {
-        var d = allDesktops[i];
-        d.wallpaperPlugin = 'org.kde.image';
-        d.currentConfigGroup = ['Wallpaper', 'org.kde.image', 'General'];
-        d.writeConfig('Image', 'file://${imagePath}');
-      }
-    `
-    await execFileAsync('qdbus', [
-      'org.kde.plasmashell',
-      '/PlasmaShell',
-      'org.kde.PlasmaShell.evaluateScript',
-      script
-    ])
-    return
+  const backend = pickLinuxBackend()
+  if (!backend) {
+    throw new Error(
+      'No supported wallpaper backend found. Please install swww (Wayland) or feh (X11).'
+    )
   }
+  await applyWithBackend(backend, imagePath)
+}
 
-  if (ds === 'wayland') {
-    if (isCommandAvailable('swww')) {
-      await execFileAsync('swww', ['img', imagePath, '--transition-type', 'fade'])
-      return
-    }
-    if (isCommandAvailable('swaybg')) {
-      await execFileAsync('swaybg', ['-i', imagePath, '-m', 'fill'])
-      return
-    }
-  }
+function pickLinuxBackend(): WallpaperBackend | null {
+  const de = detectDesktopEnv()
+  if (de === 'gnome') return 'gsettings'
+  if (de === 'kde' && isCommandAvailable('qdbus')) return 'qdbus'
 
-  if (isCommandAvailable('feh')) {
-    await execFileAsync('feh', ['--bg-fill', imagePath])
-    return
-  }
-  if (isCommandAvailable('xwallpaper')) {
-    await execFileAsync('xwallpaper', ['--zoom', imagePath])
-    return
+  if (detectDisplayServer() === 'wayland') {
+    if (isCommandAvailable('swww')) return 'swww'
+    if (isCommandAvailable('swaybg')) return 'swaybg'
   }
 
-  throw new Error(
-    'No supported wallpaper backend found. Please install swww (Wayland) or feh (X11).'
-  )
+  if (isCommandAvailable('feh')) return 'feh'
+  if (isCommandAvailable('xwallpaper')) return 'xwallpaper'
+  return null
 }
 
 async function applyWithBackend(backend: WallpaperBackend, imagePath: string): Promise<void> {

+ 19 - 12
src/main/services/watcher.service.ts

@@ -34,17 +34,24 @@ export function restartWatcher(): void {
   console.log('[Watcher] Watching', watchPath)
 
   activeWatcher = fs.watch(watchPath, { persistent: false }, (event, filename) => {
-    if (event === 'rename' && filename) {
-      const fullPath = path.join(watchPath, filename)
-      setTimeout(() => {
-        if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
-          console.log('[Watcher] New wallpaper detected:', filename)
-          const meta = importWallpaperById(filename)
-          if (meta && watcherWin) {
-            watcherWin.webContents.send(IpcChannels.EVENT_WALLPAPER_IMPORTED, meta)
-          }
-        }
-      }, 1000)
-    }
+    if (event !== 'rename' || !filename) return
+
+    // Give Steam a moment to finish creating the directory before importing
+    const fullPath = path.join(watchPath, filename)
+    setTimeout(() => {
+      let isDirectory = false
+      try {
+        isDirectory = fs.statSync(fullPath).isDirectory()
+      } catch {
+        return // deleted again in the meantime
+      }
+      if (!isDirectory) return
+
+      console.log('[Watcher] New wallpaper detected:', filename)
+      const meta = importWallpaperById(filename)
+      if (meta && watcherWin && !watcherWin.isDestroyed()) {
+        watcherWin.webContents.send(IpcChannels.EVENT_WALLPAPER_IMPORTED, meta)
+      }
+    }, 1000)
   })
 }

+ 1 - 1
src/main/services/workshop.service.ts

@@ -21,7 +21,7 @@ export async function queryWorkshop(params: WorkshopQueryParams): Promise<Worksh
   const result = await client.workshop.getAllItems(
     page,
     queryType,
-    13, // UGCType.All — WE items span multiple subtypes
+    13, // UGCType.All, since WE items span multiple subtypes
     appId,
     appId,
     {

+ 23 - 0
src/main/utils/platform.ts

@@ -20,6 +20,29 @@ export function detectDesktopEnv(): DesktopEnvironment {
   return 'other'
 }
 
+/**
+ * WAYLAND_DISPLAY of the user session. Electron often runs under XWayland
+ * without it set, so fall back to the session type and loginctl.
+ */
+export function getWaylandDisplay(): string | undefined {
+  if (process.env.WAYLAND_DISPLAY) return process.env.WAYLAND_DISPLAY
+  if (process.env.XDG_SESSION_TYPE === 'wayland') return 'wayland-0'
+
+  try {
+    const sessionType = execSync(
+      'loginctl show-session $(loginctl 2>/dev/null | grep $USER | head -1 | awk \'{print $1}\') -p Type --value 2>/dev/null',
+      { encoding: 'utf8', timeout: 3000 }
+    ).trim()
+    if (sessionType === 'wayland') return 'wayland-0'
+  } catch { /* loginctl not available */ }
+
+  return undefined
+}
+
+export function getXdgRuntimeDir(): string {
+  return process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`
+}
+
 /** Detect connected screen/output names for use with linux-wallpaperengine --screen-root. */
 export function getConnectedScreens(): string[] {
   if (process.platform !== 'linux') return []

+ 1 - 3
src/renderer/src/assets/index.css

@@ -1,6 +1,4 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
+@import "tailwindcss";
 
 @layer base {
   * {

+ 69 - 77
src/renderer/src/components/library/LibraryView.tsx

@@ -138,7 +138,7 @@ function TagDropdown({
           <div className="p-2">
             <input
               type="text"
-              placeholder="Search tags…"
+              placeholder="Search tags..."
               value={search}
               onChange={(e) => setSearch(e.target.value)}
               className="w-full rounded bg-white/5 px-2 py-1 text-xs text-gray-200 outline-none"
@@ -169,7 +169,6 @@ function TagDropdown({
   )
 }
 
-// ── Folder context menu ──
 function FolderMenu({
   x,
   y,
@@ -177,7 +176,6 @@ function FolderMenu({
   onRename,
   onDelete
 }: {
-  folder: WallpaperFolder
   x: number
   y: number
   onClose: () => void
@@ -419,7 +417,7 @@ export default function LibraryView() {
     queryClient.invalidateQueries({ queryKey: ['library-tags'] })
   }
 
-  // Create folder — inline input instead of prompt()
+  // Folder creation uses an inline input instead of prompt()
   const [creatingFolder, setCreatingFolder] = useState(false)
   const [newFolderName, setNewFolderName] = useState('')
 
@@ -457,7 +455,7 @@ export default function LibraryView() {
     setImportStatus(
       removed > 0
         ? `Cleaned up ${removed} non-existent wallpaper(s) from folders.`
-        : 'All folder items are valid — nothing to clean up.'
+        : 'All folder items are valid, nothing to clean up.'
     )
   }
 
@@ -647,7 +645,6 @@ export default function LibraryView() {
       }
     : null
 
-  // ── Wallpaper context menu handlers ──
   const openWallpaperCtxMenu = useCallback(
     (e: React.MouseEvent, wallpaperId: string) => {
       e.preventDefault()
@@ -760,7 +757,7 @@ export default function LibraryView() {
           />
           <input
             type="text"
-            placeholder="Search library…"
+            placeholder="Search library..."
             value={search}
             onChange={(e) => setSearch(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"
@@ -865,7 +862,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} imported{scanResult.removed > 0 ? `, ${scanResult.removed} removed` : ''}
+          Scan complete: {scanResult.imported} imported{scanResult.removed > 0 ? `, ${scanResult.removed} removed` : ''}
         </div>
       )}
 
@@ -936,7 +933,7 @@ export default function LibraryView() {
                   if (e.key === 'Enter') handleCreateFolderSubmit()
                   if (e.key === 'Escape') setCreatingFolder(false)
                 }}
-                placeholder="Folder name…"
+                placeholder="Folder name..."
                 className="flex-1 bg-transparent text-xs text-gray-200 outline-none placeholder-gray-600"
               />
             </div>
@@ -1007,7 +1004,7 @@ export default function LibraryView() {
             <div className="flex h-40 flex-col items-center justify-center gap-2 text-gray-500">
               <p>
                 {activeFolder
-                  ? 'This folder is empty — drag wallpapers here'
+                  ? 'This folder is empty. Drag wallpapers here.'
                   : activeCount > 0
                     ? 'No wallpapers match your filters'
                     : 'Your library is empty'}
@@ -1126,7 +1123,6 @@ export default function LibraryView() {
       {/* Folder context menu */}
       {folderMenu && (
         <FolderMenu
-          folder={folderMenu.folder}
           x={folderMenu.x}
           y={folderMenu.y}
           onClose={() => setFolderMenu(null)}
@@ -1161,87 +1157,83 @@ export default function LibraryView() {
 
           <div className="my-1 border-t border-white/5" />
 
-            <button
-              onClick={() => ctxVote(true)}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              <ThumbsUp size={12} /> Like
-            </button>
-            <button
-              onClick={() => ctxVote(false)}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              <ThumbsDown size={12} /> Dislike
-            </button>
+          <button
+            onClick={() => ctxVote(true)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <ThumbsUp size={12} /> Like
+          </button>
+          <button
+            onClick={() => ctxVote(false)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <ThumbsDown size={12} /> Dislike
+          </button>
+
+          <div className="my-1 border-t border-white/5" />
 
-            <div className="my-1 border-t border-white/5" />
+          <button
+            onClick={ctxOpenInSteam}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <ExternalLink size={12} /> Open in Steam Workshop
+          </button>
+          <button
+            onClick={ctxOpenLocally}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <FolderOpen size={12} /> Open wallpaper locally
+          </button>
 
-            {/* File operations */}
+          {ctxHasVideo && (
             <button
-              onClick={ctxOpenInSteam}
+              onClick={ctxPreviewVideo}
               className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
             >
-              <ExternalLink size={12} /> Open in Steam Workshop
+              <Eye size={12} /> Preview in media player
             </button>
+          )}
+
+          <div className="my-1 border-t border-white/5" />
+
+          <div className="relative">
             <button
-              onClick={ctxOpenLocally}
+              onClick={() => setCtxMenu((prev) => prev ? { ...prev, showFolderSub: !prev.showFolderSub } : null)}
               className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
             >
-              <FolderOpen size={12} /> Open wallpaper locally
+              <FolderInput size={12} /> Move to folder
+              <ChevronRight size={12} className="ml-auto" />
             </button>
-
-            {/* Preview video - only for video type wallpapers */}
-            {ctxHasVideo && (
-              <button
-                onClick={ctxPreviewVideo}
-                className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-              >
-                <Eye size={12} /> Preview in media player
-              </button>
-            )}
-
-            <div className="my-1 border-t border-white/5" />
-
-            {/* Move to folder - with submenu */}
-            <div className="relative">
-              <button
-                onClick={() => setCtxMenu((prev) => prev ? { ...prev, showFolderSub: !prev.showFolderSub } : null)}
-                className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-              >
-                <FolderInput size={12} /> Move to folder
-                <ChevronRight size={12} className="ml-auto" />
-              </button>
-              {ctxMenu.showFolderSub && (
-                <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
+            {ctxMenu.showFolderSub && (
+              <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
+                <button
+                  onClick={ctxMoveToDefault}
+                  className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+                >
+                  <Folder size={12} /> Default
+                </button>
+                {folders.length > 0 && <div className="my-1 border-t border-white/5" />}
+                {folders.map((f) => (
                   <button
-                    onClick={ctxMoveToDefault}
+                    key={f.id}
+                    onClick={() => ctxMoveToFolder(f.id)}
                     className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
                   >
-                    <Folder size={12} /> Default
+                    <Folder size={12} /> {f.title}
                   </button>
-                  {folders.length > 0 && <div className="my-1 border-t border-white/5" />}
-                  {folders.map((f) => (
-                    <button
-                      key={f.id}
-                      onClick={() => ctxMoveToFolder(f.id)}
-                      className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-                    >
-                      <Folder size={12} /> {f.title}
-                    </button>
-                  ))}
-                </div>
-              )}
-            </div>
-
-            {/* Remove from folder - only when inside a folder */}
-            {activeFolder && (
-              <button
-                onClick={ctxRemoveFromFolder}
-                className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
-              >
-                <Trash2 size={12} /> Remove from folder
-              </button>
+                ))}
+              </div>
             )}
+          </div>
+
+          {activeFolder && (
+            <button
+              onClick={ctxRemoveFromFolder}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
+            >
+              <Trash2 size={12} /> Remove from folder
+            </button>
+          )}
         </div>
       )}
     </div>

+ 1 - 2
src/renderer/src/components/library/WallpaperCard.tsx

@@ -3,7 +3,6 @@ import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download } from '
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
 
-
 interface WallpaperCardProps {
   wallpaper: WallpaperMeta
   selected?: boolean
@@ -133,7 +132,7 @@ export default function WallpaperCard({
         {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>
+            <span className="text-xs text-gray-300">Downloading...</span>
           </div>
         )}
       </div>

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

@@ -168,7 +168,7 @@ export default function WorkshopBrowser() {
     setFilters((prev) => ({ ...prev, [key]: toggle(prev[key] as string[], tag) }))
   }
 
-  // "all selected" means no filter — treat as if nothing is selected for that category
+  // 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
   const effectiveAgeRatings = filters.ageRatings.length === ALL_AGE_RATINGS.length ? [] : filters.ageRatings
@@ -345,7 +345,7 @@ 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..."
             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"

+ 19 - 19
src/renderer/src/constants/weFilters.ts

@@ -44,45 +44,45 @@ export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
     label: 'Widescreen',
     items: [
       { tag: 'Standard Definition', label: 'Standard Definition' },
-      { tag: '1280 x 720', label: '1280 × 720' },
-      { tag: '1366 x 768', label: '1366 × 768' },
-      { tag: '1920 x 1080', label: '1920 × 1080' },
-      { tag: '2560 x 1440', label: '2560 × 1440' },
-      { tag: '3840 x 2160', label: '3840 × 2160' }
+      { tag: '1280 x 720', label: '1280 x 720' },
+      { tag: '1366 x 768', label: '1366 x 768' },
+      { tag: '1920 x 1080', label: '1920 x 1080' },
+      { tag: '2560 x 1440', label: '2560 x 1440' },
+      { tag: '3840 x 2160', label: '3840 x 2160' }
     ]
   },
   {
     label: 'Ultrawide',
     items: [
-      { tag: 'Ultrawide 2560 x 1080', label: '2560 × 1080' },
-      { tag: 'Ultrawide 3440 x 1440', label: '3440 × 1440' },
-      { tag: 'Ultrawide 3840 x 1600', label: '3840 × 1600' }
+      { tag: 'Ultrawide 2560 x 1080', label: '2560 x 1080' },
+      { tag: 'Ultrawide 3440 x 1440', label: '3440 x 1440' },
+      { tag: 'Ultrawide 3840 x 1600', label: '3840 x 1600' }
     ]
   },
   {
     label: 'Dual Monitor',
     items: [
-      { tag: 'Dual 3840 x 1080', label: '3840 × 1080' },
-      { tag: 'Dual 5120 x 1440', label: '5120 × 1440' },
-      { tag: 'Dual 7680 x 2160', label: '7680 × 2160' }
+      { tag: 'Dual 3840 x 1080', label: '3840 x 1080' },
+      { tag: 'Dual 5120 x 1440', label: '5120 x 1440' },
+      { tag: 'Dual 7680 x 2160', label: '7680 x 2160' }
     ]
   },
   {
     label: 'Triple Monitor',
     items: [
-      { tag: 'Triple 4096 x 768', label: '4096 × 768' },
-      { tag: 'Triple 5760 x 1080', label: '5760 × 1080' },
-      { tag: 'Triple 7680 x 1440', label: '7680 × 1440' },
-      { tag: 'Triple 11520 x 2160', label: '11520 × 2160' }
+      { tag: 'Triple 4096 x 768', label: '4096 x 768' },
+      { tag: 'Triple 5760 x 1080', label: '5760 x 1080' },
+      { tag: 'Triple 7680 x 1440', label: '7680 x 1440' },
+      { tag: 'Triple 11520 x 2160', label: '11520 x 2160' }
     ]
   },
   {
     label: 'Portrait / Phone',
     items: [
-      { tag: 'Portrait 720 x 1280', label: '720 × 1280' },
-      { tag: 'Portrait 1080 x 1920', label: '1080 × 1920' },
-      { tag: 'Portrait 1440 x 2560', label: '1440 × 2560' },
-      { tag: 'Portrait 2160 x 3840', label: '2160 × 3840' }
+      { tag: 'Portrait 720 x 1280', label: '720 x 1280' },
+      { tag: 'Portrait 1080 x 1920', label: '1080 x 1920' },
+      { tag: 'Portrait 1440 x 2560', label: '1440 x 2560' },
+      { tag: 'Portrait 2160 x 3840', label: '2160 x 3840' }
     ]
   },
   {

+ 0 - 27
src/shared/constants.ts

@@ -1,28 +1 @@
 export const WE_APP_ID = 431960
-
-export const WALLPAPER_TYPES = ['scene', 'video', 'web', 'application'] as const
-
-export const WORKSHOP_TAGS = [
-  'Abstract',
-  'Animal',
-  'Anime',
-  'Cartoon',
-  'CGI',
-  'Cyberpunk',
-  'Fantasy',
-  'Game',
-  'Girls',
-  'Landscape',
-  'MMD',
-  'Nature',
-  'Retro',
-  'Sci-fi',
-  'Sports',
-  'Technology',
-  'TV Show',
-  'Vehicle',
-  'Video',
-  'Unspecified'
-] as const
-
-export const DEFAULT_ITEMS_PER_PAGE = 30

+ 0 - 21
tailwind.config.ts

@@ -1,21 +0,0 @@
-import type { Config } from 'tailwindcss'
-
-export default {
-  content: [
-    './src/renderer/index.html',
-    './src/renderer/src/**/*.{ts,tsx}'
-  ],
-  theme: {
-    extend: {
-      colors: {
-        surface: {
-          DEFAULT: '#0f0f0f',
-          '50': '#1a1a1a',
-          '100': '#252525',
-          '200': '#333333'
-        }
-      }
-    }
-  },
-  plugins: []
-} satisfies Config

+ 2 - 2
tsconfig.node.json

@@ -2,8 +2,8 @@
   "compilerOptions": {
     "composite": true,
     "target": "ES2022",
-    "module": "CommonJS",
-    "moduleResolution": "node",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
     "strict": true,
     "esModuleInterop": true,
     "skipLibCheck": true,

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
tsconfig.web.tsbuildinfo


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio