Ver Fonte

added broken downloads, fixed steam booting after app

UwU há 2 meses atrás
pai
commit
b9dae7a520

+ 3 - 2
package.json

@@ -1,9 +1,10 @@
 {
   "name": "we-manager",
-  "version": "0.1.0",
+  "version": "0.2.1",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
-  "author": "UwU",
+  "author": "UwU <260361707+WallpaperEngineLover@users.noreply.github.com>",
+  "email": "260361707+WallpaperEngineLover@users.noreply.github.com",
   "license": "MIT",
   "main": "out/main/index.js",
   "scripts": {

+ 44 - 22
src/main/ipc/steam.handlers.ts

@@ -4,6 +4,35 @@ import { IpcChannels } from '@shared/ipc-channels'
 import * as steam from '../services/steam.service'
 import * as library from '../services/library.service'
 
+function startDownloadPoll(win: BrowserWindow, itemId: string): void {
+  const idBig = BigInt(itemId)
+  const pollInterval = setInterval(() => {
+    if (win.isDestroyed()) {
+      clearInterval(pollInterval)
+      return
+    }
+    const info = steam.getDownloadInfo(idBig)
+    if (info) {
+      win.webContents.send(IpcChannels.EVENT_DOWNLOAD_PROGRESS, info)
+      return
+    }
+    if (steam.isItemDownloading(idBig)) return // queued/pending, no progress numbers yet
+
+    clearInterval(pollInterval)
+    library.importWallpaperById(itemId).then((meta) => {
+      if (win.isDestroyed()) return
+      const success = !!meta && !meta.downloading && !meta.downloadFailed
+      win.webContents.send(IpcChannels.EVENT_DOWNLOAD_PROGRESS, {
+        itemId,
+        bytesDownloaded: 0,
+        bytesTotal: 0,
+        percentage: success ? 100 : 0,
+        status: success ? 'completed' : 'error'
+      })
+    })
+  }, 1000)
+}
+
 export function registerSteamHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.STEAM_IS_RUNNING, () => {
     return steam.isSteamRunning()
@@ -11,28 +40,7 @@ export function registerSteamHandlers(win: BrowserWindow): void {
 
   ipcMain.handle(IpcChannels.STEAM_SUBSCRIBE, async (_e, itemId: string) => {
     await steam.subscribeToItem(BigInt(itemId))
-
-    // 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)
-        win.webContents.send(IpcChannels.EVENT_DOWNLOAD_PROGRESS, {
-          itemId,
-          bytesDownloaded: 0,
-          bytesTotal: 0,
-          percentage: 100,
-          status: 'completed'
-        })
-        return
-      }
-      win.webContents.send(IpcChannels.EVENT_DOWNLOAD_PROGRESS, info)
-    }, 1000)
-
+    startDownloadPoll(win, itemId)
     return { ok: true }
   })
 
@@ -46,6 +54,20 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.STEAM_REDOWNLOAD, async (_e, itemId: string) => {
+    const wallpaper = library.getWallpaper(itemId)
+    await steam.unsubscribeFromItem(BigInt(itemId))
+    if (wallpaper?.localPath) {
+      try { fs.rmSync(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
+    }
+    if (wallpaper) {
+      library.updateWallpaper(itemId, { downloading: true, downloadFailed: false })
+    }
+    await steam.subscribeToItem(BigInt(itemId))
+    startDownloadPoll(win, itemId)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.STEAM_VOTE, (_e, itemId: string, voteUp: boolean) => {
     steam.voteOnItem(BigInt(itemId), voteUp)
     return { ok: true }

+ 43 - 28
src/main/services/library.service.ts

@@ -7,6 +7,7 @@ import { randomUUID } from 'crypto'
 import { cleanupPlaylists } from './playlist.service'
 import { getWorkshopTimesUpdated } from './workshop.service'
 import { findAndMutate } from '../utils/collections'
+import { isSteamRunning, isItemDownloading } from './steam.service'
 
 interface LibraryStore {
   wallpapers: Record<string, WallpaperMeta>
@@ -216,7 +217,7 @@ function buildMeta(
   workshopTimeUpdated?: number
 ): WallpaperMeta {
   const now = Date.now()
-  const { downloading: _d, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
+  const { downloading: _d, downloadFailed: _df, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
   return {
     ...existingRest,
     id: workshopId,
@@ -237,18 +238,45 @@ function buildMeta(
   }
 }
 
+function buildIncompleteMeta(
+  workshopId: string,
+  localPath: string,
+  existing?: WallpaperMeta
+): WallpaperMeta {
+  const now = Date.now()
+  const downloading = isSteamRunning() ? isItemDownloading(BigInt(workshopId)) : true
+  return {
+    id: workshopId,
+    title: existing?.title ?? `Wallpaper ${workshopId}`,
+    type: existing?.type ?? 'scene',
+    contentRating: existing?.contentRating ?? 'everyone',
+    localPath,
+    createdAt: existing?.createdAt ?? now,
+    updatedAt: now,
+    subscribed: true,
+    appliedCount: existing?.appliedCount ?? 0,
+    source: 'workshop',
+    tags: existing?.tags ?? [],
+    categories: existing?.categories ?? [],
+    downloading,
+    downloadFailed: !downloading
+  }
+}
+
 export async function importWallpaperById(workshopId: string): Promise<WallpaperMeta | null> {
   const localPath = path.join(getWorkshopPath(), workshopId)
   if (!fs.existsSync(localPath)) return null
 
+  const existing = getWallpaper(workshopId) ?? undefined
+  const pj = readProjectJson(localPath)
+  if (!pj) {
+    const meta = buildIncompleteMeta(workshopId, localPath, existing)
+    upsertWallpaper(meta)
+    return meta
+  }
+
   const timesUpdated = await safeGetWorkshopTimesUpdated([workshopId])
-  const meta = buildMeta(
-    workshopId,
-    localPath,
-    readProjectJson(localPath),
-    getWallpaper(workshopId) ?? undefined,
-    timesUpdated.get(workshopId)
-  )
+  const meta = buildMeta(workshopId, localPath, pj, existing, timesUpdated.get(workshopId))
   upsertWallpaper(meta)
   return meta
 }
@@ -283,33 +311,20 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
     const pj = readProjectJson(localPath)
 
     if (!pj) {
-      // No project.json yet: the directory exists but the download is incomplete
-      if (!wallpapers[entry.name]) {
-        const now = Date.now()
-        wallpapers[entry.name] = {
-          id: entry.name,
-          title: `Wallpaper ${entry.name}`,
-          type: 'scene',
-          contentRating: 'everyone',
-          localPath,
-          createdAt: now,
-          updatedAt: now,
-          subscribed: true,
-          appliedCount: 0,
-          source: 'workshop',
-          tags: [],
-          categories: [],
-          downloading: true
-        }
+      const existing = wallpapers[entry.name]
+      const meta = buildIncompleteMeta(entry.name, localPath, existing)
+      if (!existing) {
+        wallpapers[entry.name] = meta
         imported++
+      } else if (existing.downloading !== meta.downloading || existing.downloadFailed !== meta.downloadFailed) {
+        wallpapers[entry.name] = { ...existing, downloading: meta.downloading, downloadFailed: meta.downloadFailed }
+        skipped++
       } else {
         skipped++
       }
       continue
     }
 
-    // Skip already-imported entries that have all required fields (cache hit),
-    // but still track them so their updatedAt can be resynced with Steam below.
     const existing = wallpapers[entry.name]
     if (!existing?.downloading && existing?.previewLocal !== undefined && existing?.contentRating !== undefined && existing?.fileSize !== undefined) {
       cacheHits.push(entry.name)

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

@@ -19,10 +19,12 @@ export function initSteam(): boolean {
 }
 
 export function isSteamRunning(): boolean {
+  if (!client) initSteam()
   return client !== null
 }
 
 export function getClient() {
+  if (!client) initSteam()
   if (!client) throw new Error('Steam not initialized')
   return client
 }
@@ -154,3 +156,12 @@ export function getItemState(itemId: bigint): number {
     return 0
   }
 }
+
+// EItemState flags from the Steamworks SDK (isteamugc.h)
+const ITEM_STATE_DOWNLOAD_PENDING = 32
+const ITEM_STATE_DOWNLOADING = 16
+
+export function isItemDownloading(itemId: bigint): boolean {
+  const state = getItemState(itemId)
+  return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
+}

+ 2 - 0
src/preload/index.ts

@@ -33,6 +33,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.STEAM_SUBSCRIBE, itemId),
     unsubscribe: (itemId: string): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.STEAM_UNSUBSCRIBE, itemId),
+    redownload: (itemId: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_REDOWNLOAD, itemId),
     getSubscribedItems: (): Promise<string[]> =>
       ipcRenderer.invoke(IpcChannels.STEAM_GET_SUBSCRIBED),
     downloadInfo: (itemId: string): Promise<DownloadProgressEvent | null> =>

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

@@ -20,7 +20,8 @@ import {
   ThumbsUp,
   ThumbsDown,
   Eraser,
-  Archive
+  Archive,
+  AlertTriangle
 } from 'lucide-react'
 import WallpaperCard from './WallpaperCard'
 import PreviewSizeToggle from '../common/PreviewSizeToggle'
@@ -40,9 +41,16 @@ interface LibraryFilterState {
   ageRatings: string[]
   genres: string[]
   sources: string[]
+  failedOnly: boolean
 }
 
-const DEFAULT_STATE: LibraryFilterState = { types: [], ageRatings: [], genres: [], sources: [] }
+const DEFAULT_STATE: LibraryFilterState = {
+  types: [],
+  ageRatings: [],
+  genres: [],
+  sources: [],
+  failedOnly: false
+}
 
 const TYPE_MAP: Record<string, string> = {
   Scene: 'scene',
@@ -267,15 +275,30 @@ export default function LibraryView() {
     localStorage.setItem(STORAGE_KEY, JSON.stringify(filterState))
   }, [filterState])
 
-  function updateFilter<K extends keyof LibraryFilterState>(key: K, tag: string) {
+  function updateFilter<K extends 'types' | 'ageRatings' | 'genres' | 'sources'>(key: K, tag: string) {
     setFilterState((prev) => ({ ...prev, [key]: toggle(prev[key], tag) }))
   }
 
+  function toggleFailedOnly() {
+    setFilterState((prev) => ({ ...prev, failedOnly: !prev.failedOnly }))
+  }
+
   const activeCount =
     filterState.types.length +
     filterState.ageRatings.length +
     filterState.genres.length +
-    filterState.sources.length
+    filterState.sources.length +
+    (filterState.failedOnly ? 1 : 0)
+
+  // Refresh the library once a background download (subscribe or redownload) settles,
+  // so the downloading/downloadFailed badges stay in sync without a manual Scan.
+  useEffect(() => {
+    return window.electronAPI.on.downloadProgress((progress) => {
+      if (progress.status === 'completed' || progress.status === 'error') {
+        queryClient.invalidateQueries({ queryKey: ['library'] })
+      }
+    })
+  }, [queryClient])
 
   const filters: LibraryFilters = {
     sortBy,
@@ -321,6 +344,7 @@ export default function LibraryView() {
         const sources = filterState.sources.map((s) => SOURCE_MAP[s]).filter(Boolean)
         if (!sources.includes(w.source)) return false
       }
+      if (filterState.failedOnly && !w.downloadFailed) return false
       return true
     })
   }, [allWallpapers, filterState])
@@ -331,6 +355,11 @@ export default function LibraryView() {
     [allWallpapers]
   )
 
+  const failedCount = useMemo(
+    () => allWallpapers.filter((w) => w.downloadFailed).length,
+    [allWallpapers]
+  )
+
   // Set of all IDs that belong to at least one folder AND exist in the library
   const allFolderItemIds = useMemo(() => {
     const set = new Set<string>()
@@ -900,6 +929,27 @@ export default function LibraryView() {
 
         <div className="h-3 w-px bg-white/10" />
 
+        <button
+          onClick={toggleFailedOnly}
+          title="Show only wallpapers whose download failed (e.g. disk ran out of space)"
+          className={clsx(
+            'flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
+            filterState.failedOnly
+              ? 'bg-red-600 text-white'
+              : failedCount > 0
+                ? 'text-red-400 hover:bg-white/5'
+                : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'
+          )}
+        >
+          <AlertTriangle size={12} />
+          Failed
+          {failedCount > 0 && (
+            <span className="rounded-full bg-black/30 px-1.5">{failedCount}</span>
+          )}
+        </button>
+
+        <div className="h-3 w-px bg-white/10" />
+
         <TagDropdown
           available={availableTags}
           selected={filterState.genres}
@@ -1164,6 +1214,9 @@ export default function LibraryView() {
                   queryClient.invalidateQueries({ queryKey: ['library'] })
                   refetchFolders()
                 }}
+                onRedownloaded={() =>
+                  queryClient.invalidateQueries({ queryKey: ['library'] })
+                }
                 onSelect={(e) => handleCardSelect(wallpaper.id, e)}
                 onContextMenu={(e) => openWallpaperCtxMenu(e, wallpaper.id)}
               />

+ 73 - 6
src/renderer/src/components/library/WallpaperCard.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download, Archive } from 'lucide-react'
+import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download, Archive, AlertTriangle, RotateCw } from 'lucide-react'
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
 import { getPreviewSrc } from '../../utils/preview'
@@ -15,6 +15,7 @@ interface WallpaperCardProps {
   onLiked?: () => void
   onUnsubscribed?: () => void
   onBackedUp?: () => void
+  onRedownloaded?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
 }
@@ -29,6 +30,7 @@ export default function WallpaperCard({
   onLiked,
   onUnsubscribed,
   onBackedUp,
+  onRedownloaded,
   onSelect,
   onContextMenu
 }: WallpaperCardProps) {
@@ -39,6 +41,8 @@ export default function WallpaperCard({
   const [fpsInput, setFpsInput] = useState(wallpaper.fpsOverride != null ? String(wallpaper.fpsOverride) : '')
   const [isBackingUp, setIsBackingUp] = useState(false)
   const [backupPercentage, setBackupPercentage] = useState<number | null>(null)
+  const [isRedownloading, setIsRedownloading] = useState(false)
+  const [downloadPercentage, setDownloadPercentage] = useState<number | null>(null)
 
   useEffect(() => {
     if (!isBackingUp) return
@@ -53,6 +57,37 @@ export default function WallpaperCard({
     })
   }, [isBackingUp, wallpaper.id])
 
+  useEffect(() => {
+    if (!wallpaper.downloading) {
+      setDownloadPercentage(null)
+      return
+    }
+    return window.electronAPI.on.downloadProgress((progress) => {
+      if (progress.itemId !== wallpaper.id) return
+      if (progress.status === 'downloading') {
+        setDownloadPercentage(progress.percentage)
+      } else {
+        setDownloadPercentage(null)
+        setIsRedownloading(false)
+        onRedownloaded?.()
+      }
+    })
+  }, [wallpaper.downloading, wallpaper.id, onRedownloaded])
+
+  async function handleRedownload(e: React.MouseEvent) {
+    e.stopPropagation()
+    if (isRedownloading) return
+    setIsRedownloading(true)
+    setError(null)
+    try {
+      await window.electronAPI.steam.redownload(wallpaper.id)
+      onRedownloaded?.()
+    } catch (err) {
+      setError((err as Error).message)
+      setIsRedownloading(false)
+    }
+  }
+
   async function handleApply(e: React.MouseEvent) {
     e.stopPropagation()
     setIsApplying(true)
@@ -164,9 +199,33 @@ export default function WallpaperCard({
           <div className="flex h-full items-center justify-center text-gray-600">No preview</div>
         )}
         {wallpaper.downloading && (
-          <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/70">
+          <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/70 px-4">
             <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">
+              {downloadPercentage != null ? `Downloading... ${downloadPercentage}%` : 'Downloading...'}
+            </span>
+            {downloadPercentage != null && (
+              <div className="h-1 w-full max-w-[140px] overflow-hidden rounded-full bg-white/10">
+                <div
+                  className="h-full bg-indigo-500 transition-[width]"
+                  style={{ width: `${downloadPercentage}%` }}
+                />
+              </div>
+            )}
+          </div>
+        )}
+        {wallpaper.downloadFailed && (
+          <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/70 px-4 text-center">
+            <AlertTriangle size={20} className="text-red-400" />
+            <span className="text-xs text-gray-300">Download failed</span>
+            <button
+              onClick={handleRedownload}
+              disabled={isRedownloading}
+              className="flex items-center gap-1 rounded bg-indigo-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-indigo-600 disabled:cursor-not-allowed disabled:opacity-50"
+            >
+              {isRedownloading ? <Loader2 size={11} className="animate-spin" /> : <RotateCw size={11} />}
+              Redownload
+            </button>
           </div>
         )}
         {(wallpaper.backedUp || wallpaper.source === 'backup') && (
@@ -278,11 +337,19 @@ export default function WallpaperCard({
       <div className="absolute right-2 top-2">
         <button
           onClick={handleApply}
-          disabled={isApplying || !lweInstalled || wallpaper.downloading}
-          title={wallpaper.downloading ? 'Wallpaper is still downloading' : !lweInstalled ? 'Install linux-wallpaperengine in Settings first' : 'Play wallpaper'}
+          disabled={isApplying || !lweInstalled || wallpaper.downloading || wallpaper.downloadFailed}
+          title={
+            wallpaper.downloading
+              ? 'Wallpaper is still downloading'
+              : wallpaper.downloadFailed
+                ? 'Download failed - use Redownload to try again'
+                : !lweInstalled
+                  ? 'Install linux-wallpaperengine in Settings first'
+                  : 'Play wallpaper'
+          }
           className={clsx(
             'flex h-7 w-7 items-center justify-center rounded-full transition-opacity disabled:cursor-not-allowed',
-            !lweInstalled || wallpaper.downloading
+            !lweInstalled || wallpaper.downloading || wallpaper.downloadFailed
               ? 'bg-black/60 text-gray-500 opacity-0 group-hover:opacity-100'
               : 'bg-black/60 text-white opacity-0 hover:bg-indigo-600 group-hover:opacity-100'
           )}

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

@@ -14,6 +14,7 @@ export const IpcChannels = {
   STEAM_VOTE: 'steam:vote',
   STEAM_OPEN_WORKSHOP: 'steam:open-workshop',
   STEAM_GET_VOTED_IDS: 'steam:get-voted-ids',
+  STEAM_REDOWNLOAD: 'steam:redownload',
 
   // Library management
   LIBRARY_GET_ALL: 'library:get-all',

+ 1 - 0
src/shared/types.ts

@@ -43,6 +43,7 @@ export interface WallpaperMeta {
   tags: string[]
   categories: string[]
   downloading?: boolean
+  downloadFailed?: boolean
   fpsOverride?: number
   backedUp?: boolean
 }