Forráskód Böngészése

a lot of backup fixes, scan is now a background async job, wallpapers are being verified after backup

UwU 2 napja
szülő
commit
27675224df

+ 1 - 1
package.json

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

+ 76 - 25
src/main/ipc/backup.handlers.ts

@@ -1,37 +1,68 @@
 import { ipcMain, BrowserWindow } from 'electron'
 import * as fs from 'fs'
+import * as path from 'path'
 import { IpcChannels } from '@shared/ipc-channels'
 import * as backup from '../services/backup.service'
 import * as library from '../services/library.service'
 import * as steam from '../services/steam.service'
 import { getAutoUnsubscribeAfterBackup } from '../services/config.service'
 
-async function backupOne(id: string, win: BrowserWindow): Promise<{ ok: boolean; unsubscribed: boolean }> {
-  const wallpaper = library.getWallpaper(id)
-  if (!wallpaper?.localPath) throw new Error(`Wallpaper ${id} has no local files to back up`)
+export interface BackupItemResult {
+  ok: boolean
+  alreadyBackedUp: boolean
+  unsubscribed: boolean
+}
 
-  await backup.backupWallpaper(id, wallpaper.localPath, win)
-  library.updateWallpaper(id, { backedUp: true })
+const inFlight = new Set<string>()
 
-  let unsubscribed = false
-  if (getAutoUnsubscribeAfterBackup()) {
-    await steam.unsubscribeFromItem(BigInt(id))
-    try {
-      await fs.promises.rm(wallpaper.localPath, { recursive: true, force: true })
-    } catch {
-      /* ignore */
+async function backupOne(id: string, win: BrowserWindow): Promise<BackupItemResult> {
+  if (inFlight.has(id)) throw new Error(`Wallpaper ${id} is already being backed up`)
+  inFlight.add(id)
+  try {
+    const wallpaper = library.getWallpaper(id)
+    if (!wallpaper) throw new Error(`Wallpaper ${id} is not in the library`)
+    if (wallpaper.source !== 'workshop') throw new Error(`Wallpaper ${id} is not a workshop wallpaper`)
+    if (wallpaper.downloading) throw new Error(`Wallpaper ${id} is still downloading`)
+    if (!wallpaper.localPath || !(await backup.hasBackupFiles(wallpaper.localPath))) {
+      throw new Error(`Wallpaper ${id} has no complete local files to back up`)
     }
-    library.updateWallpaper(id, {
-      source: 'backup',
-      localPath: backup.getBackupDir(id),
-      subscribed: false,
-      downloading: false,
-      downloadFailed: false
-    })
-    unsubscribed = true
-  }
+    const sourcePath = wallpaper.localPath
 
-  return { ok: true, unsubscribed }
+    let backupDir = backup.resolveBackupDir(wallpaper)
+    const alreadyBackedUp =
+      !!backupDir && (await backup.hasBackupFiles(backupDir)) && (await backup.backupMatchesSource(sourcePath, backupDir))
+    if (!alreadyBackedUp) {
+      backupDir = await backup.backupWallpaper(id, sourcePath, win)
+    }
+    library.updateWallpaper(id, { backedUp: true, backupDir: backupDir! })
+
+    let unsubscribed = false
+    if (getAutoUnsubscribeAfterBackup()) {
+      if (!(await backup.hasBackupFiles(backupDir))) {
+        throw new Error(`Backup of ${id} is missing after copying, not unsubscribing`)
+      }
+      await steam.unsubscribeFromItem(BigInt(id))
+      library.updateWallpaper(id, {
+        source: 'backup',
+        localPath: backupDir!,
+        subscribed: false,
+        downloading: false,
+        downloadFailed: false
+      })
+      if (path.resolve(sourcePath) !== path.resolve(backupDir!)) {
+        try {
+          await fs.promises.rm(sourcePath, { recursive: true, force: true })
+        } catch {
+          /* ignore */
+        }
+      }
+      unsubscribed = true
+    }
+
+    return { ok: true, alreadyBackedUp, unsubscribed }
+  } finally {
+    inFlight.delete(id)
+  }
 }
 
 export function registerBackupHandlers(win: BrowserWindow): void {
@@ -41,23 +72,43 @@ export function registerBackupHandlers(win: BrowserWindow): void {
 
   ipcMain.handle(IpcChannels.BACKUP_SELECTION, async (_e, itemIds: string[]) => {
     let backedUp = 0
+    let alreadyBackedUp = 0
     let failed = 0
     let unsubscribed = 0
 
     for (const id of itemIds) {
       try {
         const result = await backupOne(id, win)
-        backedUp++
+        if (result.alreadyBackedUp) alreadyBackedUp++
+        else backedUp++
         if (result.unsubscribed) unsubscribed++
-      } catch {
+      } catch (err) {
+        console.error(`[Backup] ${id}:`, err)
         failed++
       }
     }
 
-    return { backedUp, failed, unsubscribed }
+    return { backedUp, alreadyBackedUp, failed, unsubscribed }
   })
 
   ipcMain.handle(IpcChannels.BACKUP_SCAN, () => {
     return backup.scanBackupFolder()
   })
+
+  ipcMain.handle(IpcChannels.BACKUP_REMOVE, async (_e, itemIds: string[]) => {
+    let removed = 0
+    let failed = 0
+    const keptDirs: string[] = []
+    for (const id of itemIds) {
+      try {
+        const { keptDir } = await backup.removeBackup(id)
+        if (keptDir) keptDirs.push(keptDir)
+        removed++
+      } catch (err) {
+        console.error(`[Backup] Failed to remove backup of ${id}:`, err)
+        failed++
+      }
+    }
+    return { removed, failed, keptDirs }
+  })
 }

+ 17 - 20
src/main/ipc/steam.handlers.ts

@@ -58,31 +58,28 @@ 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))
+    // a backup entry's localPath is the backup itself
+    if (wallpaper && wallpaper.source !== 'workshop') {
+      library.updateWallpaper(itemId, { subscribed: false, downloading: false, downloadFailed: false })
+      return { ok: true }
+    }
     if (wallpaper?.localPath) {
       // async: an unresponsive mount here must not block the main process (and every other
       // pending IPC call) while it deletes
       try { await fs.promises.rm(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
     }
-    // Trust the stored flag alone: it's set once, when a backup actually succeeds. Re-checking
-    // the filesystem here (backup.isBackedUp) can disagree with it - e.g. if the configured
-    // backup folder changed since - and must never be allowed to turn "this is backed up" into
-    // "delete the library entry".
-    if (wallpaper?.backedUp) {
-      // getBackupDir() throws if the backup path setting was since cleared entirely - still
-      // must not fall through to deleting a wallpaper we know is backed up.
-      try {
-        library.updateWallpaper(itemId, {
-          source: 'backup',
-          localPath: backup.getBackupDir(itemId),
-          subscribed: false,
-          downloading: false,
-          downloadFailed: false
-        })
-      } catch (err) {
-        library.updateWallpaper(itemId, { subscribed: false, downloading: false, downloadFailed: false })
-        console.error('[Steam] Failed to resolve backup dir on unsubscribe:', err)
-      }
+    const backupDir = wallpaper?.backedUp ? backup.resolveBackupDir(wallpaper) : null
+    if (wallpaper && backupDir && (await backup.hasBackupFiles(backupDir))) {
+      library.updateWallpaper(itemId, {
+        source: 'backup',
+        localPath: backupDir,
+        backupDir,
+        subscribed: false,
+        downloading: false,
+        downloadFailed: false
+      })
     } else {
+      if (wallpaper?.backedUp) console.warn(`[Steam] Backup of ${itemId} not found at ${backupDir}, removing the entry`)
       library.deleteWallpaper(itemId)
     }
     return { ok: true }
@@ -91,7 +88,7 @@ export function registerSteamHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.STEAM_REDOWNLOAD, async (_e, itemId: string) => {
     const wallpaper = library.getWallpaper(itemId)
     await steam.unsubscribeFromItem(BigInt(itemId))
-    if (wallpaper?.localPath) {
+    if (wallpaper?.localPath && wallpaper.source === 'workshop') {
       try { await fs.promises.rm(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
     }
     if (wallpaper) {

+ 163 - 0
src/main/services/backup.service.test.ts

@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import * as fs from 'fs'
+import * as os from 'os'
+import * as path from 'path'
+import type { WallpaperMeta } from '@shared/types'
+
+let backupRoot = ''
+const wallpapers = new Map<string, WallpaperMeta>()
+
+vi.mock('electron', () => ({ app: { getPath: () => os.tmpdir() } }))
+vi.mock('./config.service', () => ({ getConfiguredBackupPath: () => backupRoot }))
+vi.mock('./library.service', () => ({
+  getWallpaper: (id: string) => wallpapers.get(id) ?? null,
+  getAllWallpapers: () => [...wallpapers.values()],
+  updateWallpaper: (id: string, patch: Partial<WallpaperMeta>) =>
+    wallpapers.set(id, { ...wallpapers.get(id)!, ...patch }),
+  upsertWallpaper: (meta: WallpaperMeta) => wallpapers.set(meta.id, meta),
+  deleteWallpaper: (id: string) => wallpapers.delete(id),
+  applyWallpaperChanges: (c: {
+    upserts?: WallpaperMeta[]
+    patches?: { id: string; patch: Partial<WallpaperMeta> }[]
+    deletes?: string[]
+  }) => {
+    for (const m of c.upserts ?? []) wallpapers.set(m.id, m)
+    for (const { id, patch } of c.patches ?? []) wallpapers.set(id, { ...wallpapers.get(id)!, ...patch })
+    for (const id of c.deletes ?? []) wallpapers.delete(id)
+  },
+  normalizeType: (t?: string) => t ?? 'scene',
+  normalizeRating: () => 'everyone',
+}))
+
+const backup = await import('./backup.service')
+
+const win = { isDestroyed: () => false, webContents: { send: vi.fn<() => void>() } } as never
+
+let tmp = ''
+let source = ''
+
+function writeFiles(dir: string, files: Record<string, string>): void {
+  for (const [rel, content] of Object.entries(files)) {
+    fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true })
+    fs.writeFileSync(path.join(dir, rel), content)
+  }
+}
+
+beforeEach(() => {
+  tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wem-backup-'))
+  backupRoot = path.join(tmp, 'backups')
+  source = path.join(tmp, 'workshop', '123')
+  writeFiles(source, { 'project.json': '{}', 'materials/a.tex': 'aaaa', 'scene.pkg': 'pkg' })
+  wallpapers.clear()
+})
+
+afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }))
+
+describe('backupWallpaper', () => {
+  it('copies, verifies and leaves no staging folder behind', async () => {
+    const dir = await backup.backupWallpaper('123', source, win)
+    expect(dir).toBe(path.join(backupRoot, '123'))
+    expect(await backup.backupMatchesSource(source, dir)).toBe(true)
+    expect(fs.readdirSync(backupRoot)).toEqual(['123'])
+  })
+
+  it('refuses a folder without project.json', async () => {
+    fs.rmSync(path.join(source, 'project.json'))
+    await expect(backup.backupWallpaper('123', source, win)).rejects.toThrow(/project.json/)
+    expect(fs.existsSync(path.join(backupRoot, '123'))).toBe(false)
+  })
+
+  it('replaces a corrupted backup', async () => {
+    const dir = await backup.backupWallpaper('123', source, win)
+    fs.writeFileSync(path.join(dir, 'materials/a.tex'), 'aaab')
+    expect(await backup.backupMatchesSource(source, dir)).toBe(false)
+    fs.writeFileSync(path.join(dir, 'stray'), 'x')
+    await backup.backupWallpaper('123', source, win)
+    expect(await backup.backupMatchesSource(source, dir)).toBe(true)
+    expect(fs.readdirSync(backupRoot)).toEqual(['123'])
+  })
+})
+
+describe('backupMatchesSource', () => {
+  it('detects missing files and size changes', async () => {
+    const dir = await backup.backupWallpaper('123', source, win)
+    fs.rmSync(path.join(dir, 'scene.pkg'))
+    expect(await backup.backupMatchesSource(source, dir)).toBe(false)
+    expect(await backup.backupMatchesSource(source, path.join(tmp, 'nope'))).toBe(false)
+  })
+})
+
+const base = { title: 't', type: 'scene', contentRating: 'everyone', createdAt: 0, updatedAt: 0, subscribed: false, appliedCount: 0, tags: [], categories: [] } as unknown as WallpaperMeta
+
+describe('removeBackup', () => {
+
+  it('drops a backup entry whose folder no longer exists', async () => {
+    wallpapers.set('9', { ...base, id: '9', source: 'backup', localPath: path.join(tmp, 'gone', '9'), backedUp: true })
+    await backup.removeBackup('9')
+    expect(wallpapers.has('9')).toBe(false)
+  })
+
+  it('deletes the backup of a workshop entry and keeps the workshop copy', async () => {
+    const dir = await backup.backupWallpaper('123', source, win)
+    wallpapers.set('123', { ...base, id: '123', source: 'workshop', localPath: source, backedUp: true, backupDir: dir })
+    await backup.removeBackup('123')
+    expect(fs.existsSync(dir)).toBe(false)
+    expect(fs.existsSync(source)).toBe(true)
+    expect(wallpapers.get('123')?.backedUp).toBe(false)
+  })
+
+  it('never deletes a folder outside the backup folder', async () => {
+    const outside = path.join(tmp, 'elsewhere')
+    writeFiles(outside, { 'project.json': '{}' })
+    wallpapers.set('7', { ...base, id: '7', source: 'backup', localPath: outside, backedUp: true })
+    const { keptDir } = await backup.removeBackup('7')
+    expect(keptDir).toBe(outside)
+    expect(fs.existsSync(outside)).toBe(true)
+    expect(wallpapers.has('7')).toBe(false)
+  })
+})
+
+describe('scanBackupFolder', () => {
+  it('drops missing and corrupted backup entries and skips corrupted folders', async () => {
+    writeFiles(path.join(backupRoot, 'good'), { 'project.json': '{"type":"video","file":"a.mp4"}', 'a.mp4': 'v' })
+    writeFiles(path.join(backupRoot, 'nofile'), { 'project.json': '{"type":"video","file":"a.mp4"}' })
+    writeFiles(path.join(backupRoot, 'badjson'), { 'project.json': '{nope' })
+    writeFiles(path.join(backupRoot, 'scene'), { 'project.json': '{"type":"scene","file":"scene.json"}', 'scene.pkg': 'p' })
+    writeFiles(path.join(backupRoot, 'preset'), { 'project.json': '{"dependency":"1"}' })
+    wallpapers.set('gone', { ...base, id: 'gone', source: 'backup', localPath: path.join(backupRoot, 'gone'), backedUp: true })
+    wallpapers.set('nofile', { ...base, id: 'nofile', source: 'backup', localPath: path.join(backupRoot, 'nofile'), backedUp: true })
+    wallpapers.set('offline', { ...base, id: 'offline', source: 'backup', localPath: path.join(tmp, 'unmounted', 'offline'), backedUp: true })
+
+    const result = (await backup.scanBackupFolder())
+    expect(result.removed).toBe(2)
+    expect(result.corrupted.sort()).toEqual(['badjson', 'nofile'])
+    expect([...wallpapers.keys()].sort()).toEqual(['good', 'offline', 'preset', 'scene'])
+    expect(wallpapers.get('good')?.fileSize).toBe(fs.statSync(path.join(backupRoot, 'good', 'project.json')).size + 1)
+
+    const again = await backup.scanBackupFolder()
+    expect(again.imported).toBe(0)
+    expect(again.skipped).toBe(3)
+  })
+
+  it('re-imports a backup entry that pointed at a folder in the old backup location', async () => {
+    writeFiles(path.join(backupRoot, 'moved'), { 'project.json': '{}' })
+    fs.mkdirSync(path.join(tmp, 'old-backups'))
+    wallpapers.set('moved', { ...base, id: 'moved', source: 'backup', localPath: path.join(tmp, 'old-backups', 'moved'), backedUp: true, fileSize: 1 })
+    const result = await backup.scanBackupFolder()
+    expect(result.removed).toBe(0)
+    expect(result.imported).toBe(1)
+    expect(wallpapers.get('moved')?.localPath).toBe(path.join(backupRoot, 'moved'))
+  })
+
+  it('unlinks a workshop entry whose backup differs from the workshop copy', async () => {
+    const dir = await backup.backupWallpaper('123', source, win)
+    wallpapers.set('123', { ...base, id: '123', source: 'workshop', localPath: source, backedUp: true, backupDir: dir })
+    expect((await backup.scanBackupFolder()).unlinked).toBe(0)
+
+    fs.writeFileSync(path.join(dir, 'scene.pkg'), 'truncated-and-longer')
+    const result = (await backup.scanBackupFolder())
+    expect(result.unlinked).toBe(1)
+    expect(result.corrupted).toEqual(['123'])
+    expect(wallpapers.get('123')?.backedUp).toBe(false)
+  })
+})

+ 325 - 100
src/main/services/backup.service.ts

@@ -1,12 +1,13 @@
 import { BrowserWindow } from 'electron'
+import { spawn } from 'child_process'
 import * as crypto from 'crypto'
 import * as fs from 'fs'
 import * as path from 'path'
 import { IpcChannels } from '@shared/ipc-channels'
-import type { WallpaperMeta } from '@shared/types'
+import type { BackupProgressEvent, WallpaperMeta } from '@shared/types'
 import { getConfiguredBackupPath } from './config.service'
 import * as library from './library.service'
-import { readProjectJson, normalizeType, normalizeRating, getDirSize } from './library.service'
+import { normalizeType, normalizeRating, type ProjectJson } from './library.service'
 
 export function getBackupDir(id: string): string {
   const backupPath = getConfiguredBackupPath()
@@ -14,9 +15,62 @@ export function getBackupDir(id: string): string {
   return path.join(backupPath, id)
 }
 
-export function isBackedUp(id: string): boolean {
+export function resolveBackupDir(wallpaper: WallpaperMeta): string | null {
+  if (wallpaper.source === 'backup' && wallpaper.localPath) return wallpaper.localPath
+  if (wallpaper.backupDir) return wallpaper.backupDir
   try {
-    return fs.existsSync(getBackupDir(id))
+    return getBackupDir(wallpaper.id)
+  } catch {
+    return null
+  }
+}
+
+async function isDir(p: string): Promise<boolean> {
+  try {
+    return (await fs.promises.stat(p)).isDirectory()
+  } catch {
+    return false
+  }
+}
+
+async function exists(p: string): Promise<boolean> {
+  try {
+    await fs.promises.access(p)
+    return true
+  } catch {
+    return false
+  }
+}
+
+async function readProjectJson(dir: string): Promise<ProjectJson | null> {
+  try {
+    return JSON.parse(await fs.promises.readFile(path.join(dir, 'project.json'), 'utf8')) as ProjectJson
+  } catch {
+    return null
+  }
+}
+
+export async function hasBackupFiles(dir: string | null): Promise<boolean> {
+  return !!dir && (await isDir(dir)) && (await exists(path.join(dir, 'project.json')))
+}
+
+export async function findBackupProblem(dir: string): Promise<string | null> {
+  if (!(await isDir(dir))) return 'folder is missing'
+  const pj = await readProjectJson(dir)
+  if (!pj) return 'project.json is missing or unreadable'
+  if (!pj.file) return null
+  if (await exists(path.join(dir, pj.file))) return null
+  if (normalizeType(pj.type) === 'scene' && (await fs.promises.readdir(dir)).some((f) => f.endsWith('.pkg'))) {
+    return null
+  }
+  return `${pj.file} is missing`
+}
+
+async function sameFileList(sourcePath: string, backupDir: string): Promise<boolean> {
+  try {
+    const [src, dst] = await Promise.all([walkFiles(sourcePath), walkFiles(backupDir)])
+    const dstSizes = new Map(dst.map((f) => [f.relPath, f.size]))
+    return src.length === dst.length && src.every((f) => dstSizes.get(f.relPath) === f.size)
   } catch {
     return false
   }
@@ -32,49 +86,116 @@ function hashFile(filePath: string): Promise<string> {
   })
 }
 
-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}`
-    )
+async function walkFiles(dirPath: string, baseDir = dirPath): Promise<{ relPath: string; size: number }[]> {
+  const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
+  const nested = await Promise.all(
+    entries.map(async (entry) => {
+      const full = path.join(dirPath, entry.name)
+      if (entry.isDirectory()) return walkFiles(full, baseDir)
+      if (!entry.isFile()) return []
+      return [{ relPath: path.relative(baseDir, full), size: (await fs.promises.stat(full)).size }]
+    })
+  )
+  return nested.flat()
+}
+
+async function walkDirs(dirPath: string): Promise<string[]> {
+  const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
+  const nested = await Promise.all(
+    entries.filter((e) => e.isDirectory()).map((e) => walkDirs(path.join(dirPath, e.name)))
+  )
+  return [dirPath, ...nested.flat()]
+}
+
+async function forEachLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
+  let next = 0
+  const worker = async (): Promise<void> => {
+    while (next < items.length) await fn(items[next++])
   }
-  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`)
+  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
+}
+
+async function fsyncPath(p: string): Promise<void> {
+  const handle = await fs.promises.open(p, 'r')
+  try {
+    await handle.sync()
+  } finally {
+    await handle.close()
   }
 }
 
-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 })) {
-    const full = path.join(dirPath, entry.name)
-    if (entry.isDirectory()) {
-      results.push(...walkFiles(full, baseDir))
-    } else if (entry.isFile()) {
-      const rel = path.relative(baseDir, full)
-      results.push({ relPath: rel, size: fs.statSync(full).size })
-    }
+// fsynced pages are clean, so iflag=nocache can evict them and the hash check reads the disk
+function dropPageCache(dir: string): Promise<void> {
+  return new Promise((resolve) => {
+    const child = spawn(
+      'find',
+      [dir, '-type', 'f', '-exec', 'sh', '-c',
+        'for f do dd if="$f" iflag=nocache count=0 status=none; done', 'sh', '{}', '+'],
+      { stdio: 'ignore' }
+    )
+    child.on('error', () => resolve())
+    child.on('close', () => resolve())
+  })
+}
+
+function sendProgress(win: BrowserWindow, progress: BackupProgressEvent): void {
+  if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, progress)
+}
+
+function percent(done: number, total: number): number {
+  return total > 0 ? Math.round((done / total) * 100) : 100
+}
+
+export async function backupMatchesSource(sourcePath: string, backupDir: string): Promise<boolean> {
+  let src: { relPath: string; size: number }[]
+  let dst: { relPath: string; size: number }[]
+  try {
+    ;[src, dst] = await Promise.all([walkFiles(sourcePath), walkFiles(backupDir)])
+  } catch {
+    return false
+  }
+  if (src.length !== dst.length) return false
+  const dstSizes = new Map(dst.map((f) => [f.relPath, f.size]))
+  if (src.some((f) => dstSizes.get(f.relPath) !== f.size)) return false
+
+  for (const file of src) {
+    const [a, b] = await Promise.all([
+      hashFile(path.join(sourcePath, file.relPath)),
+      hashFile(path.join(backupDir, file.relPath))
+    ])
+    if (a !== b) return false
   }
-  return results
+  return true
 }
 
 export async function backupWallpaper(
   id: string,
   sourcePath: string,
   win: BrowserWindow
-): Promise<void> {
+): Promise<string> {
   const destDir = getBackupDir(id)
-  const files = walkFiles(sourcePath)
+  const backupRoot = path.dirname(destDir)
+  if (path.resolve(sourcePath) === path.resolve(destDir)) {
+    throw new Error('Wallpaper already lives in the backup folder')
+  }
+
+  const files = await walkFiles(sourcePath)
+  if (!files.some((f) => f.relPath === 'project.json')) {
+    throw new Error('Source folder has no project.json, refusing to back up an incomplete wallpaper')
+  }
   const bytesTotal = files.reduce((sum, f) => sum + f.size, 0)
-  let bytesCopied = 0
 
-  fs.mkdirSync(destDir, { recursive: true })
+  await fs.promises.mkdir(backupRoot, { recursive: true })
+  const stagingDir = path.join(backupRoot, `.${id}.partial`)
+  const oldDir = path.join(backupRoot, `.${id}.old`)
+  await fs.promises.rm(stagingDir, { recursive: true, force: true })
+  await fs.promises.mkdir(stagingDir)
 
+  let bytesDone = 0
   try {
     for (const file of files) {
       const srcFile = path.join(sourcePath, file.relPath)
-      const destFile = path.join(destDir, file.relPath)
+      const destFile = path.join(stagingDir, file.relPath)
 
       let srcStat: fs.Stats
       try {
@@ -86,68 +207,105 @@ export async function backupWallpaper(
         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.mkdir(path.dirname(destFile), { recursive: true })
       await fs.promises.copyFile(srcFile, destFile)
+      await fsyncPath(destFile)
+      bytesDone += file.size
+      sendProgress(win, {
+        itemId: id,
+        bytesCopied: bytesDone,
+        bytesTotal,
+        percentage: percent(bytesDone, bytesTotal),
+        status: 'copying'
+      })
+    }
+    for (const dir of await walkDirs(stagingDir)) await fsyncPath(dir)
+    await dropPageCache(stagingDir)
 
-      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'
-        })
+    bytesDone = 0
+    for (const file of files) {
+      const destFile = path.join(stagingDir, file.relPath)
+      const destSize = (await fs.promises.stat(destFile)).size
+      if (destSize !== file.size) {
+        throw new Error(
+          `Backup verification failed for ${file.relPath}: expected ${file.size} bytes, got ${destSize}`
+        )
       }
-
-      await verifyCopy(srcFile, destFile, file.size)
-      bytesCopied += file.size
-
-      if (!win.isDestroyed()) {
-        win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
-          itemId: id,
-          bytesCopied,
-          bytesTotal,
-          percentage: bytesTotal > 0 ? Math.round((bytesCopied / bytesTotal) * 100) : 100,
-          status: 'copying'
-        })
+      const [srcHash, destHash] = await Promise.all([
+        hashFile(path.join(sourcePath, file.relPath)),
+        hashFile(destFile)
+      ])
+      if (srcHash !== destHash) {
+        throw new Error(`Backup verification failed for ${file.relPath}: hash mismatch`)
       }
-    }
-  } catch (err) {
-    try {
-      await fs.promises.rm(destDir, { recursive: true, force: true })
-    } catch {
-      /* ignore */
-    }
-    if (!win.isDestroyed()) {
-      win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
+      bytesDone += file.size
+      sendProgress(win, {
         itemId: id,
-        bytesCopied,
+        bytesCopied: bytesDone,
         bytesTotal,
-        percentage: 0,
-        status: 'error',
-        message: (err as Error).message
+        percentage: percent(bytesDone, bytesTotal),
+        status: 'verifying'
       })
     }
-    throw err
-  }
 
-  if (!win.isDestroyed()) {
-    win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
+    await fs.promises.rm(oldDir, { recursive: true, force: true })
+    const hadOld = await exists(destDir)
+    if (hadOld) await fs.promises.rename(destDir, oldDir)
+    await fs.promises.rename(stagingDir, destDir)
+    await fsyncPath(backupRoot)
+    if (hadOld) await fs.promises.rm(oldDir, { recursive: true, force: true })
+  } catch (err) {
+    await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => {})
+    sendProgress(win, {
       itemId: id,
-      bytesCopied,
+      bytesCopied: bytesDone,
       bytesTotal,
-      percentage: 100,
-      status: 'completed'
+      percentage: 0,
+      status: 'error',
+      message: (err as Error).message
     })
+    throw err
   }
+
+  sendProgress(win, { itemId: id, bytesCopied: bytesTotal, bytesTotal, percentage: 100, status: 'completed' })
+  return destDir
 }
 
-function buildBackupMeta(
-  id: string,
-  localPath: string,
-  existing?: WallpaperMeta
-): WallpaperMeta {
-  const pj = readProjectJson(localPath)
+function isDeletableBackupDir(wallpaper: WallpaperMeta, dir: string): boolean {
+  const resolved = path.resolve(dir)
+  if (wallpaper.backupDir && path.resolve(wallpaper.backupDir) === resolved) return true
+  if (wallpaper.source === 'backup' && path.basename(resolved) === wallpaper.id) return true
+  const backupPath = getConfiguredBackupPath()
+  return !!backupPath && path.dirname(resolved) === path.resolve(backupPath)
+}
+
+export async function removeBackup(id: string): Promise<{ keptDir: string | null }> {
+  const wallpaper = library.getWallpaper(id)
+  if (!wallpaper) return { keptDir: null }
+  const dir = resolveBackupDir(wallpaper)
+  let keptDir: string | null = null
+
+  if (dir && (await exists(dir))) {
+    const isWorkshopCopy =
+      wallpaper.source === 'workshop' && !!wallpaper.localPath && path.resolve(wallpaper.localPath) === path.resolve(dir)
+    if (isWorkshopCopy || !isDeletableBackupDir(wallpaper, dir)) {
+      keptDir = dir
+      console.warn(`[Backup] Not deleting ${dir} for ${id}, only dropping the reference`)
+    } else {
+      await fs.promises.rm(dir, { recursive: true, force: true })
+    }
+  }
+
+  if (wallpaper.source === 'backup') {
+    library.deleteWallpaper(id)
+  } else {
+    library.updateWallpaper(id, { backedUp: false, backupDir: undefined })
+  }
+  return { keptDir }
+}
+
+async function buildBackupMeta(id: string, localPath: string, existing?: WallpaperMeta): Promise<WallpaperMeta> {
+  const [pj, files] = await Promise.all([readProjectJson(localPath), walkFiles(localPath)])
   const now = Date.now()
   return {
     id,
@@ -158,7 +316,7 @@ function buildBackupMeta(
     previewLocal: pj?.preview ? path.join(localPath, pj.preview) : undefined,
     localPath,
     file: pj?.file,
-    fileSize: getDirSize(localPath),
+    fileSize: files.reduce((sum, f) => sum + f.size, 0),
     createdAt: existing?.createdAt ?? now,
     updatedAt: now,
     subscribed: false,
@@ -167,39 +325,106 @@ function buildBackupMeta(
     tags: pj?.tags ?? existing?.tags ?? [],
     resolutions: existing?.resolutions,
     categories: existing?.categories ?? [],
-    backedUp: true
+    backedUp: true,
+    backupDir: localPath
   }
 }
 
-export function scanBackupFolder(): { imported: number; skipped: number; linked: number } {
+const SCAN_CONCURRENCY = 4
+
+export async function scanBackupFolder(): Promise<{
+  imported: number
+  skipped: number
+  linked: number
+  removed: number
+  unlinked: number
+  corrupted: string[]
+}> {
+  const result = { imported: 0, skipped: 0, linked: 0, removed: 0, unlinked: 0, corrupted: [] as string[] }
   const backupPath = getConfiguredBackupPath()
-  if (!backupPath || !fs.existsSync(backupPath)) return { imported: 0, skipped: 0, linked: 0 }
+  if (!backupPath || !(await isDir(backupPath))) return result
+
+  const wallpapers = new Map(library.getAllWallpapers().map((w) => [w.id, w]))
+  const folderIds = (await fs.promises.readdir(backupPath, { withFileTypes: true }))
+    // dot folders are staging copies of an in-progress backup
+    .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
+    .map((e) => e.name)
 
-  let imported = 0
-  let skipped = 0
-  let linked = 0
+  const problems = new Map<string, Promise<string | null>>()
+  const problemOf = (dir: string): Promise<string | null> => {
+    const key = path.resolve(dir)
+    if (!problems.has(key)) problems.set(key, findBackupProblem(dir))
+    return problems.get(key)!
+  }
+  const isValidFor = async (w: WallpaperMeta, dir: string): Promise<boolean> => {
+    if (await problemOf(dir)) return false
+    if (w.source !== 'workshop' || !w.localPath || !(await isDir(w.localPath))) return true
+    return sameFileList(w.localPath, dir)
+  }
+
+  const upserts: WallpaperMeta[] = []
+  const patches: { id: string; patch: Partial<WallpaperMeta> }[] = []
+  const deletes: string[] = []
 
-  for (const entry of fs.readdirSync(backupPath, { withFileTypes: true })) {
-    if (!entry.isDirectory()) continue
+  const referenced = [...wallpapers.values()].filter((w) => (w.source === 'backup' || w.backedUp) && !w.downloading)
+  await forEachLimit(referenced, SCAN_CONCURRENCY, async (w) => {
+    const dir = resolveBackupDir(w)
+    // the drive holding it is not mounted
+    if (dir && !(await isDir(path.dirname(dir)))) return
+    if (dir && (await isValidFor(w, dir))) return
+    const reason = dir ? ((await problemOf(dir)) ?? 'does not match the workshop copy') : 'no backup folder'
+    console.warn(`[Backup] Dropping broken backup of ${w.id} at ${dir}: ${reason}`)
+    if (w.source === 'backup') {
+      deletes.push(w.id)
+      result.removed++
+    } else {
+      patches.push({ id: w.id, patch: { backedUp: false, backupDir: undefined } })
+      result.unlinked++
+    }
+  })
+  const dropped = new Set([...deletes, ...patches.map((p) => p.id)])
 
-    const id = entry.name
-    const localBackupPath = path.join(backupPath, id)
-    const existing = library.getWallpaper(id)
+  await forEachLimit(folderIds, SCAN_CONCURRENCY, async (id) => {
+    const dir = path.join(backupPath, id)
+    const problem = await problemOf(dir)
+    if (problem) {
+      console.warn(`[Backup] Skipping corrupted backup ${dir}: ${problem}`)
+      result.corrupted.push(id)
+      return
+    }
+    const existing = wallpapers.get(id)
 
-    if (existing && existing.source === 'workshop') {
-      if (!existing.backedUp) {
-        library.updateWallpaper(id, { backedUp: true })
-        linked++
+    if (existing?.source === 'workshop') {
+      if ((existing.backedUp && !dropped.has(id)) || existing.downloading) {
+        result.skipped++
+      } else if (await isValidFor(existing, dir)) {
+        patches.push({ id, patch: { backedUp: true, backupDir: dir } })
+        result.linked++
       } else {
-        skipped++
+        result.corrupted.push(id)
       }
-      continue
+      return
     }
+    if (
+      existing?.source === 'backup' &&
+      !dropped.has(id) &&
+      existing.fileSize !== undefined &&
+      path.resolve(existing.localPath ?? '') === path.resolve(dir)
+    ) {
+      result.skipped++
+      return
+    }
+    upserts.push(await buildBackupMeta(id, dir, existing))
+    result.imported++
+  })
 
-    const meta = buildBackupMeta(id, localBackupPath, existing ?? undefined)
-    library.upsertWallpaper(meta)
-    imported++
-  }
-
-  return { imported, skipped, linked }
+  const reimported = new Set(upserts.map((m) => m.id))
+  library.applyWallpaperChanges({
+    upserts,
+    patches,
+    deletes: deletes.filter((id) => !reimported.has(id))
+  })
+  result.removed -= deletes.filter((id) => reimported.has(id)).length
+  result.corrupted.sort()
+  return result
 }

+ 23 - 1
src/main/services/library.service.ts

@@ -129,6 +129,25 @@ export function deleteWallpaper(id: string): void {
   store.set('wallpapers', wallpapers)
 }
 
+// reads the store fresh so changes made during a long scan aren't overwritten
+export function applyWallpaperChanges(changes: {
+  upserts?: WallpaperMeta[]
+  patches?: { id: string; patch: Partial<WallpaperMeta> }[]
+  deletes?: string[]
+}): void {
+  const wallpapers = store.get('wallpapers')
+  for (const meta of changes.upserts ?? []) wallpapers[meta.id] = meta
+  for (const { id, patch } of changes.patches ?? []) {
+    if (wallpapers[id]) wallpapers[id] = { ...wallpapers[id], ...patch, updatedAt: wallpapers[id].updatedAt }
+  }
+  for (const id of changes.deletes ?? []) {
+    const thumbnail = wallpapers[id]?.customPreview
+    if (thumbnail && path.dirname(thumbnail) === getThumbnailsPath()) fs.rmSync(thumbnail, { force: true })
+    delete wallpapers[id]
+  }
+  store.set('wallpapers', wallpapers)
+}
+
 export function resetAllFpsOverrides(): { count: number } {
   const wallpapers = store.get('wallpapers')
   let count = 0
@@ -328,6 +347,7 @@ function buildMeta(
     updatedAt: workshopTimeUpdated ? workshopTimeUpdated * 1000 : (existing?.updatedAt ?? now),
     subscribed: true,
     source: 'workshop',
+    backupDir: existing?.backupDir ?? (existing?.source === 'backup' ? existing.localPath : undefined),
     tags: pj?.tags ?? existing?.tags ?? [],
     resolutions: liveWorkshopTags ? deriveResolutionsFromTags(liveWorkshopTags) : existing?.resolutions,
     authorSteamId: authorSteamId ?? existing?.authorSteamId
@@ -365,7 +385,9 @@ function buildIncompleteMeta(
     categories: existing?.categories ?? [],
     downloading,
     downloadFailed: failed,
-    authorSteamId: authorSteamId ?? existing?.authorSteamId
+    authorSteamId: authorSteamId ?? existing?.authorSteamId,
+    backedUp: existing?.backedUp,
+    backupDir: existing?.backupDir ?? (existing?.source === 'backup' ? existing.localPath : undefined)
   }
 }
 

+ 14 - 4
src/preload/index.ts

@@ -342,12 +342,22 @@ const api = {
   },
 
   backup: {
-    item: (itemId: string): Promise<{ ok: boolean; unsubscribed: boolean }> =>
+    item: (itemId: string): Promise<{ ok: boolean; alreadyBackedUp: boolean; unsubscribed: boolean }> =>
       ipcRenderer.invoke(IpcChannels.BACKUP_ITEM, itemId),
-    selection: (itemIds: string[]): Promise<{ backedUp: number; failed: number; unsubscribed: number }> =>
+    selection: (
+      itemIds: string[]
+    ): Promise<{ backedUp: number; alreadyBackedUp: number; failed: number; unsubscribed: number }> =>
       ipcRenderer.invoke(IpcChannels.BACKUP_SELECTION, itemIds),
-    scan: (): Promise<{ imported: number; skipped: number; linked: number }> =>
-      ipcRenderer.invoke(IpcChannels.BACKUP_SCAN)
+    scan: (): Promise<{
+      imported: number
+      skipped: number
+      linked: number
+      removed: number
+      unlinked: number
+      corrupted: string[]
+    }> => ipcRenderer.invoke(IpcChannels.BACKUP_SCAN),
+    remove: (itemIds: string[]): Promise<{ removed: number; failed: number; keptDirs: string[] }> =>
+      ipcRenderer.invoke(IpcChannels.BACKUP_REMOVE, itemIds)
   },
 
   desktopIcons: {

+ 50 - 39
src/renderer/src/components/library/LibraryView.tsx

@@ -344,6 +344,10 @@ interface LibraryViewProps {
   onBrowseCreator?: (creatorSteamId: string) => void
 }
 
+function backupSummary(result: { backedUp: number; alreadyBackedUp: number; failed: number; unsubscribed: number }) {
+  return `Backed up ${result.backedUp}${result.alreadyBackedUp > 0 ? `, ${result.alreadyBackedUp} already up to date` : ''}${result.unsubscribed > 0 ? ` (${result.unsubscribed} unsubscribed)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`
+}
+
 export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
   const queryClient = useQueryClient()
   const [search, setSearch] = useState('')
@@ -669,16 +673,9 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
   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)
-
+    const toBackUp = wallpapers.filter((w) => selectedIds.has(w.id) && w.source === 'workshop')
     if (toBackUp.length === 0) {
-      setBackupStatus(
-        alreadyBackedUp.length === 1
-          ? 'Already backed up.'
-          : `All ${alreadyBackedUp.length} selected wallpapers are already backed up.`
-      )
+      setBackupStatus('No workshop wallpapers selected.')
       return
     }
 
@@ -686,9 +683,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     setBackupStatus(null)
     try {
       const result = await window.electronAPI.backup.selection(toBackUp.map((w) => w.id))
-      setBackupStatus(
-        `Backed up ${result.backedUp}${alreadyBackedUp.length > 0 ? `, ${alreadyBackedUp.length} already were` : ''}${result.unsubscribed > 0 ? ` (${result.unsubscribed} unsubscribed)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}.`
-      )
+      setBackupStatus(`${backupSummary(result)}.`)
     } finally {
       setBackingUpSelection(false)
       queryClient.invalidateQueries({ queryKey: ['library'] })
@@ -700,7 +695,10 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     setBackupStatus(null)
     try {
       const result = await window.electronAPI.backup.scan()
-      setBackupStatus(`Backup scan: ${result.imported} imported, ${result.linked} linked.`)
+      const dropped = result.removed + result.unlinked
+      setBackupStatus(
+        `Backup scan: ${result.imported} imported, ${result.linked} linked${dropped > 0 ? `, ${dropped} broken removed` : ''}${result.corrupted.length > 0 ? `, ${result.corrupted.length} corrupted folder${result.corrupted.length === 1 ? '' : 's'} skipped (${result.corrupted.join(', ')})` : ''}.`
+      )
     } finally {
       setBackupScanning(false)
       queryClient.invalidateQueries({ queryKey: ['library'] })
@@ -732,9 +730,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
       const result = await window.electronAPI.backup.selection(
         unavailableNotBackedUp.map((w) => w.id)
       )
-      setBackupStatus(
-        `Backed up ${result.backedUp}${result.unsubscribed > 0 ? ` (${result.unsubscribed} unsubscribed)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}.`
-      )
+      setBackupStatus(`${backupSummary(result)}.`)
     } finally {
       setBackingUpUnavailable(false)
       queryClient.invalidateQueries({ queryKey: ['library'] })
@@ -935,34 +931,17 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
   async function ctxBackup() {
     if (!ctxMenu) return
-    const targets = ctxWallpapers
+    const toBackUp = ctxWallpapers.filter((w) => w.source === 'workshop')
     closeCtxMenu()
-
-    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
-    }
+    if (toBackUp.length === 0) 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'
-        )
+        const result = await window.electronAPI.backup.item(toBackUp[0].id)
+        const done = result.alreadyBackedUp ? 'Backup already up to date' : 'Backed up'
+        showToast(result.unsubscribed ? `${done}, unsubscribed` : done)
       } 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` : ''}`
-        )
+        showToast(backupSummary(await window.electronAPI.backup.selection(toBackUp.map((w) => w.id))))
       }
     } catch (err) {
       showToast(`Backup failed: ${(err as Error).message}`)
@@ -970,6 +949,28 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     queryClient.invalidateQueries({ queryKey: ['library'] })
   }
 
+  async function ctxRemoveBackup() {
+    if (!ctxMenu) return
+    const targets = ctxWallpapers.filter((w) => w.backedUp || w.source === 'backup')
+    closeCtxMenu()
+    if (targets.length === 0) return
+
+    const backupOnly = targets.filter((w) => w.source === 'backup').length
+    const what = targets.length === 1 ? `the backup of "${targets[0].title}"` : `${targets.length} backups`
+    const note =
+      backupOnly > 0
+        ? `\n\n${backupOnly === 1 && targets.length === 1 ? 'It is' : `${backupOnly} of them are`} not subscribed on Steam anymore and will be removed from the library.`
+        : ''
+    if (!confirm(`Delete ${what} from the backup folder?${note}`)) return
+
+    const result = await window.electronAPI.backup.remove(targets.map((w) => w.id))
+    showToast(
+      `Removed ${result.removed} backup${result.removed === 1 ? '' : 's'}${result.keptDirs.length > 0 ? ` (${result.keptDirs.length} outside the backup folder kept on disk)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`
+    )
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+    refetchFolders()
+  }
+
   async function ctxOpenLocally() {
     if (!ctxMenu) return
     const paths = ctxWallpapers.filter((w) => w.localPath).map((w) => w.localPath!)
@@ -1011,6 +1012,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
   const ctxHasVideo = ctxWallpapers.some((w) => w.type === 'video' && w.file)
   const ctxHasBackupable = ctxWallpapers.some((w) => w.source === 'workshop')
+  const ctxHasBackup = ctxWallpapers.some((w) => w.backedUp || w.source === 'backup')
   const ctxCanBrowseCreator =
     ctxWallpapers.length === 1 &&
     ctxWallpapers[0]?.source !== 'local' &&
@@ -1647,6 +1649,15 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
             </button>
           )}
 
+          {ctxHasBackup && (
+            <button
+              onClick={ctxRemoveBackup}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
+            >
+              <Trash2 size={12} /> Delete backup
+            </button>
+          )}
+
           <button
             onClick={ctxUnsubscribe}
             className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"

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

@@ -91,6 +91,7 @@ export const IpcChannels = {
   BACKUP_ITEM: 'backup:item',
   BACKUP_SELECTION: 'backup:selection',
   BACKUP_SCAN: 'backup:scan',
+  BACKUP_REMOVE: 'backup:remove',
 
   // Playlists
   PLAYLIST_GET_ALL: 'playlist:get-all',

+ 1 - 0
src/shared/types.ts

@@ -87,6 +87,7 @@ export interface WallpaperMeta {
    */
   playbackSpeed?: number
   backedUp?: boolean
+  backupDir?: string
   /** Set by a "check unavailable" scan: true if the item has been removed from the Steam Workshop */
   unavailable?: boolean
   /** Object/layer ids or names to force-hide via linux-wallpaperengine --disable-object */