Răsfoiți Sursa

multiple votes que works now

UwU 4 zile în urmă
părinte
comite
3539666f6d

+ 4 - 1
src/main/index.ts

@@ -2,7 +2,7 @@ import { app, BrowserWindow, net, protocol, shell } from 'electron'
 import path from 'path'
 import { pathToFileURL } from 'url'
 import { IpcChannels } from '@shared/ipc-channels'
-import { initSteam, isSteamRunning, startVotedItemsSync } from './services/steam.service'
+import { initSteam, isSteamRunning, startVotedItemsSync, onFailedVotesChange } from './services/steam.service'
 import { initLibrary, checkUnavailableWallpapers, backfillResolutions } from './services/library.service'
 import { startWatcher } from './services/watcher.service'
 import { registerAllHandlers } from './ipc'
@@ -27,6 +27,9 @@ function startBackgroundSync(win: BrowserWindow): void {
   startVotedItemsSync((ids) => {
     if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_VOTED_IDS_CHANGED, ids)
   })
+  onFailedVotesChange((failed) => {
+    if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_FAILED_VOTES_CHANGED, failed)
+  })
 
   const syncResolutions = async (): Promise<void> => {
     if (await backfillResolutions() && !win.isDestroyed()) {

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

@@ -33,6 +33,8 @@ import {
   setAutostartPlaylistId,
   getKillLweOnQuit,
   setKillLweOnQuit,
+  getVoteBorders,
+  setVoteBorders,
   getAudioScreen,
   setAudioScreen,
   getAmbientVolume,
@@ -84,6 +86,7 @@ export function registerConfigHandlers(): void {
     autostartMinimized: getAutostartMinimized(),
     autostartPlaylistId: getAutostartPlaylistId(),
     killLweOnQuit: getKillLweOnQuit(),
+    voteBorders: getVoteBorders(),
     audioScreen: getAudioScreen(),
     ambientVolume: getAmbientVolume(),
     defaultAudioSensitivity: getDefaultAudioSensitivity(),
@@ -124,6 +127,11 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.CONFIG_SET_VOTE_BORDERS, (_e, enabled: boolean) => {
+    setVoteBorders(enabled)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_STEAM_IDENTITY, (_e, identity: SteamIdentity) => {
     setSteamIdentity(identity)
     return { ok: true }

+ 4 - 0
src/main/ipc/steam.handlers.ts

@@ -116,6 +116,10 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     return steam.getVotedUpItemIds()
   })
 
+  ipcMain.handle(IpcChannels.STEAM_GET_FAILED_VOTES, () => {
+    return steam.getFailedVotes()
+  })
+
   ipcMain.handle(IpcChannels.STEAM_CHECK_VOTE, (_e, itemId: string) => {
     return steam.refreshItemVote(BigInt(itemId))
   })

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

@@ -18,6 +18,8 @@ interface AppConfig {
   autostartMinimized: boolean
   autostartPlaylistId: string | null
   killLweOnQuit: boolean
+  /** Green outline on liked wallpapers, red on ones whose like failed */
+  voteBorders: boolean
   recommendedFpsEnabled: boolean
   recommendedWebFpsEnabled: boolean
   audioScreen: string | null
@@ -54,6 +56,7 @@ const store = new Store<AppConfig>({
     autostartMinimized: false,
     autostartPlaylistId: null,
     killLweOnQuit: false,
+    voteBorders: true,
     audioScreen: null,
     ambientVolume: null,
     defaultAudioSensitivity: 1,
@@ -136,6 +139,14 @@ export function setKillLweOnQuit(enabled: boolean): void {
   store.set('killLweOnQuit', enabled)
 }
 
+export function getVoteBorders(): boolean {
+  return store.get('voteBorders')
+}
+
+export function setVoteBorders(enabled: boolean): void {
+  store.set('voteBorders', enabled)
+}
+
 /** How this process identifies itself to Steam - 'wallpaper-engine' registers under WE's own
  *  app id (full workshop compatibility, but Steam treats this process as WE and closes it on
  *  logout); 'standalone' registers under a neutral test app id so Steam leaves it running.

+ 68 - 12
src/main/services/steam.service.ts

@@ -410,24 +410,80 @@ function recordVote(idStr: string, voteUp: boolean): void {
   persistVoteCache()
 }
 
+const VOTE_ATTEMPTS = 4
+const VOTE_RETRY_BASE_MS = 1500
+// Steam rejects or drops SetUserItemVote calls that overlap, so votes go out one at a time
+const VOTE_GAP_MS = 400
+
+let voteQueue: Promise<unknown> = Promise.resolve()
+
+// kept for this session only, a failure usually means Steam was busy or offline at the time
+const failedVotes = new Map<string, { up: boolean; message: string }>()
+let failedVotesChangeListener: ((failed: Record<string, string>) => void) | null = null
+
+export function getFailedVotes(): Record<string, string> {
+  const out: Record<string, string> = {}
+  for (const [id, failure] of failedVotes) {
+    // liked elsewhere since, e.g. on the website or picked up by the background sync
+    if (failure.up && votedUpCache.has(id)) failedVotes.delete(id)
+    else out[id] = failure.message
+  }
+  return out
+}
+
+export function onFailedVotesChange(cb: (failed: Record<string, string>) => void): void {
+  failedVotesChangeListener = cb
+}
+
+function setVoteFailure(idStr: string, failure: { up: boolean; message: string } | null): void {
+  if (failure === null) {
+    if (!failedVotes.delete(idStr)) return
+  } else {
+    failedVotes.set(idStr, failure)
+  }
+  failedVotesChangeListener?.(getFailedVotes())
+}
+
+export function voteOnItemAndConfirm(itemId: bigint, voteUp: boolean): Promise<boolean> {
+  const run = voteQueue.then(() => voteWithRetry(itemId, voteUp))
+  voteQueue = run.catch(() => {}).then(() => sleep(VOTE_GAP_MS))
+  return run
+}
+
+async function voteWithRetry(itemId: bigint, voteUp: boolean): Promise<boolean> {
+  let lastErr: unknown
+  for (let attempt = 0; attempt < VOTE_ATTEMPTS; attempt++) {
+    if (attempt > 0) await sleep(VOTE_RETRY_BASE_MS * 2 ** (attempt - 1))
+    if (!isSteamRunning()) throw new Error('Steam is not running - start Steam to like wallpapers')
+    if (!isSteamOnline()) throw new Error('Steam is offline - check your internet connection and try again')
+    try {
+      const ok = await voteOnce(itemId, voteUp)
+      setVoteFailure(itemId.toString(), null)
+      return ok
+    } catch (err) {
+      lastErr = err
+      console.warn(`[Steam] Vote on ${itemId} failed (attempt ${attempt + 1}/${VOTE_ATTEMPTS}):`, (err as Error)?.message ?? err)
+    }
+  }
+  const msg = (lastErr as Error)?.message ?? String(lastErr)
+  setVoteFailure(itemId.toString(), {
+    up: voteUp,
+    message: `${voteUp ? 'Like' : 'Dislike'} failed after ${VOTE_ATTEMPTS} attempts: ${msg}`
+  })
+  throw new Error(`Could not reach Steam to ${voteUp ? 'like' : 'dislike'} this wallpaper: ${msg}`)
+}
+
 // Without the patched steamworks.js, success is reported once SetUserItemVote is enqueued: its result can't be read back,
 // because steamworks.js's manual dispatch loop consumes every call result on the shared Steam pipe first.
-export async function voteOnItemAndConfirm(itemId: bigint, voteUp: boolean): Promise<boolean> {
-  if (!isSteamRunning()) throw new Error('Steam is not running - start Steam to like wallpapers')
-  const client = getClient()
-  if (!isSteamOnline()) throw new Error('Steam is offline - check your internet connection and try again')
+async function voteOnce(itemId: bigint, voteUp: boolean): Promise<boolean> {
   const idStr = itemId.toString()
-  const patched = getPatchedVoteApi(client)
+  const patched = getPatchedVoteApi(getClient())
 
   if (patched) {
-    await withTimeout(patched.voteItem(itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote').catch((err) => {
-      throw new Error(`Could not reach Steam to ${voteUp ? 'like' : 'dislike'} this wallpaper: ${err?.message ?? err}`)
-    })
-    recordVote(idStr, voteUp)
-    return true
+    await withTimeout(patched.voteItem(itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote')
+  } else {
+    voteOnItem(itemId, voteUp)
   }
-
-  voteOnItem(itemId, voteUp)
   recordVote(idStr, voteUp)
   return true
 }

+ 10 - 0
src/preload/index.ts

@@ -66,6 +66,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.STEAM_OPEN_WORKSHOP, itemId),
     getVotedIds: (): Promise<string[]> =>
       ipcRenderer.invoke(IpcChannels.STEAM_GET_VOTED_IDS),
+    getFailedVotes: (): Promise<Record<string, string>> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_GET_FAILED_VOTES),
     checkVote: (itemId: string): Promise<boolean | null> =>
       ipcRenderer.invoke(IpcChannels.STEAM_CHECK_VOTE, itemId),
     getAuthorInfo: (steamId: string): Promise<WorkshopAuthorInfo | null> =>
@@ -119,6 +121,7 @@ const api = {
       autostartMinimized: boolean
       autostartPlaylistId: string | null
       killLweOnQuit: boolean
+      voteBorders: boolean
       audioScreen: string | null
       ambientVolume: number | null
       defaultAudioSensitivity: number
@@ -164,6 +167,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_TRAY_ENABLED, enabled),
     setKillLweOnQuit: (enabled: boolean): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_KILL_LWE_ON_QUIT, enabled),
+    setVoteBorders: (enabled: boolean): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_VOTE_BORDERS, enabled),
     setAutostart: (
       enabled: boolean,
       minimized: boolean,
@@ -394,6 +399,11 @@ const api = {
       ipcRenderer.on(IpcChannels.EVENT_VOTED_IDS_CHANGED, listener)
       return () => ipcRenderer.off(IpcChannels.EVENT_VOTED_IDS_CHANGED, listener)
     },
+    failedVotesChanged: (cb: (failed: Record<string, string>) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, failed: Record<string, string>) => cb(failed)
+      ipcRenderer.on(IpcChannels.EVENT_FAILED_VOTES_CHANGED, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_FAILED_VOTES_CHANGED, listener)
+    },
     libraryChanged: (cb: () => void): (() => void) => {
       const listener = () => cb()
       ipcRenderer.on(IpcChannels.EVENT_LIBRARY_CHANGED, listener)

+ 6 - 0
src/renderer/src/App.tsx

@@ -69,6 +69,12 @@ export default function App() {
     })
   }, [queryClient])
 
+  useEffect(() => {
+    return window.electronAPI.on.failedVotesChanged((failed) => {
+      queryClient.setQueryData(['steam-failed-votes'], failed)
+    })
+  }, [queryClient])
+
   useEffect(() => {
     return window.electronAPI.on.libraryChanged(() => {
       queryClient.invalidateQueries({ queryKey: ['library'] })

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

@@ -35,6 +35,7 @@ import type { LibraryFilters, WallpaperFolder, LweStatus, ScreenTarget } from '@
 import clsx from 'clsx'
 import { WE_TYPES, WE_LIBRARY_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
+import { useVoteBorders } from '../../hooks/useVoteBorders'
 import { useSelectableGrid } from '../../hooks/useSelectableGrid'
 import { useClickOutside } from '../../hooks/useClickOutside'
 import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
@@ -458,6 +459,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     staleTime: Infinity
   })
   const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
+  const { enabled: voteBordersEnabled, failed: failedVotes } = useVoteBorders()
 
   // Client-side OR filtering for multiple types / ratings / genres
   const filtered = useMemo(() => {
@@ -882,6 +884,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     if (!ctxMenu) return
     const ids = ctxMenu.ids
     closeCtxMenu()
+    if (ids.length > 1) showToast(`${up ? 'Liking' : 'Disliking'} ${ids.length} wallpapers on Steam...`)
 
     const results = await Promise.allSettled(ids.map((id) => window.electronAPI.steam.vote(id, up)))
     const confirmedIds = ids.filter((_, i) => {
@@ -889,10 +892,11 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
       return r.status === 'fulfilled' && r.value.confirmed
     })
 
-    if (up && confirmedIds.length > 0) {
-      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) => [
-        ...new Set([...(old ?? []), ...confirmedIds])
-      ])
+    if (confirmedIds.length > 0) {
+      const confirmedSet = new Set(confirmedIds)
+      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+        up ? [...new Set([...(old ?? []), ...confirmedIds])] : (old ?? []).filter((id) => !confirmedSet.has(id))
+      )
     }
 
     const failed = ids.length - confirmedIds.length
@@ -1510,6 +1514,8 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
                 isDetailOpen={detailId === wallpaper.id}
                 lweInstalled={lweStatus?.installed ?? false}
                 isLiked={votedSet.has(wallpaper.id)}
+                voteError={failedVotes[wallpaper.id]}
+                showVoteBorder={voteBordersEnabled}
                 currentPlaylistId={currentPlaylistId}
                 isInCurrentPlaylist={currentPlaylistItemIds.has(wallpaper.id)}
                 previewSize={previewSize}

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

@@ -7,6 +7,7 @@ import { getPreviewSrc } from '../../utils/preview'
 import { openWorkshopPage } from '../../utils/steam'
 import { useToast } from '../common/Toast'
 import type { PreviewSize } from '../../hooks/usePreviewSize'
+import { voteBorderClass } from '../../hooks/useVoteBorders'
 
 interface WallpaperCardProps {
   wallpaper: WallpaperMeta
@@ -14,6 +15,8 @@ interface WallpaperCardProps {
   isDetailOpen?: boolean
   lweInstalled?: boolean
   isLiked?: boolean
+  voteError?: string
+  showVoteBorder?: boolean
   currentPlaylistId?: string | null
   isInCurrentPlaylist?: boolean
   previewSize?: PreviewSize
@@ -33,6 +36,8 @@ export default function WallpaperCard({
   isDetailOpen,
   lweInstalled,
   isLiked = false,
+  voteError,
+  showVoteBorder = false,
   currentPlaylistId = null,
   isInCurrentPlaylist = false,
   previewSize = 'normal',
@@ -177,13 +182,15 @@ export default function WallpaperCard({
       }}
       onClick={onSelect}
       onContextMenu={onContextMenu}
+      title={showVoteBorder ? voteError : undefined}
       className={clsx(
         'group relative overflow-hidden rounded-lg bg-[#1a1a1a] transition-all cursor-pointer select-none',
         isDetailOpen
           ? 'ring-2 ring-sky-400'
           : selected
             ? 'ring-2 ring-indigo-500'
-            : 'hover:ring-1 hover:ring-indigo-500/50'
+            : 'hover:ring-1 hover:ring-indigo-500/50',
+        showVoteBorder && voteBorderClass(voteError, isLiked)
       )}
     >
       <div

+ 3 - 0
src/renderer/src/components/settings/SettingsView.tsx

@@ -4,6 +4,7 @@ import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Pack
 import type { LweStatus, LweInstallProgress, LinuxDistro, SteamIdentity } from '../../../../shared/types'
 import PerformanceSettings from './PerformanceSettings'
 import DisplaySettings from './DisplaySettings'
+import VoteBorderSettings from './VoteBorderSettings'
 import ScheduleSettings from './ScheduleSettings'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
@@ -566,6 +567,8 @@ export default function SettingsView() {
 
         <DisplaySettings screens={screens} />
 
+        <VoteBorderSettings />
+
         <ScheduleSettings />
 
         <div>

+ 41 - 0
src/renderer/src/components/settings/VoteBorderSettings.tsx

@@ -0,0 +1,41 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { ThumbsUp } from 'lucide-react'
+import { useVoteBorders } from '../../hooks/useVoteBorders'
+
+export default function VoteBorderSettings() {
+  const queryClient = useQueryClient()
+  const { enabled } = useVoteBorders()
+
+  async function toggle() {
+    await window.electronAPI.config.setVoteBorders(!enabled)
+    queryClient.invalidateQueries({ queryKey: ['config'] })
+  }
+
+  return (
+    <div>
+      <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">Likes</label>
+      <label className="mt-3 flex items-center gap-3 cursor-pointer">
+        <button
+          onClick={toggle}
+          className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+            enabled ? 'bg-indigo-600' : 'bg-white/10'
+          }`}
+        >
+          <span
+            className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+              enabled ? 'translate-x-4' : 'translate-x-0.5'
+            }`}
+          />
+        </button>
+        <span className="flex items-center gap-2 text-sm text-gray-300">
+          <ThumbsUp size={16} />
+          Show like borders on wallpapers
+        </span>
+      </label>
+      <p className="mt-1 text-xs text-gray-600">
+        Green for wallpapers you liked on Steam, red for ones where the like failed after retrying (hover for the
+        reason). Failed likes are remembered until the app restarts.
+      </p>
+    </div>
+  )
+}

+ 10 - 4
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -32,6 +32,7 @@ import {
   WE_GENRES
 } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
+import { useVoteBorders } from '../../hooks/useVoteBorders'
 import { useSelectableGrid } from '../../hooks/useSelectableGrid'
 import { useClickOutside } from '../../hooks/useClickOutside'
 import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
@@ -315,6 +316,7 @@ export default function WorkshopBrowser({
     if (!ctxMenu) return
     const ids = ctxMenu.ids
     closeCtxMenu()
+    if (ids.length > 1) showToast(`${up ? 'Liking' : 'Disliking'} ${ids.length} wallpapers on Steam...`)
 
     const results = await Promise.allSettled(ids.map((id) => window.electronAPI.steam.vote(id, up)))
     const confirmedIds = ids.filter((_, i) => {
@@ -322,10 +324,11 @@ export default function WorkshopBrowser({
       return r.status === 'fulfilled' && r.value.confirmed
     })
 
-    if (up && confirmedIds.length > 0) {
-      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) => [
-        ...new Set([...(old ?? []), ...confirmedIds])
-      ])
+    if (confirmedIds.length > 0) {
+      const confirmedSet = new Set(confirmedIds)
+      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+        up ? [...new Set([...(old ?? []), ...confirmedIds])] : (old ?? []).filter((id) => !confirmedSet.has(id))
+      )
     }
 
     const failed = ids.length - confirmedIds.length
@@ -383,6 +386,7 @@ export default function WorkshopBrowser({
     staleTime: Infinity
   })
   const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
+  const { enabled: voteBordersEnabled, failed: failedVotes } = useVoteBorders()
 
   const { data: libraryWallpapers = [] } = useQuery({
     queryKey: ['library'],
@@ -935,6 +939,8 @@ export default function WorkshopBrowser({
                 isDetailOpen={detailId === item.publishedFileId}
                 ignored={ignoredCreatorSet.has(item.creatorSteamId)}
                 isLiked={votedSet.has(item.publishedFileId)}
+                voteError={failedVotes[item.publishedFileId]}
+                showVoteBorder={voteBordersEnabled}
                 canPlay={playableSet.has(item.publishedFileId)}
                 lweInstalled={lweStatus?.installed ?? false}
                 subscribeState={subscribeEntries.get(item.publishedFileId)?.state}

+ 8 - 1
src/renderer/src/components/workshop/WorkshopCard.tsx

@@ -6,6 +6,7 @@ import clsx from 'clsx'
 import { openWorkshopPage } from '../../utils/steam'
 import { formatFileSize } from '../../utils/format'
 import { useToast } from '../common/Toast'
+import { voteBorderClass } from '../../hooks/useVoteBorders'
 
 interface WorkshopCardProps {
   item: WorkshopItem
@@ -13,6 +14,8 @@ interface WorkshopCardProps {
   isDetailOpen?: boolean
   ignored?: boolean
   isLiked?: boolean
+  voteError?: string
+  showVoteBorder?: boolean
   canPlay?: boolean
   lweInstalled?: boolean
   subscribeState?: SubscribeState
@@ -30,6 +33,8 @@ export default function WorkshopCard({
   isDetailOpen = false,
   ignored = false,
   isLiked = false,
+  voteError,
+  showVoteBorder = false,
   canPlay = false,
   lweInstalled = false,
   subscribeState,
@@ -90,13 +95,15 @@ export default function WorkshopCard({
   return (
     <div
       data-workshop-id={item.publishedFileId}
+      title={showVoteBorder ? voteError : undefined}
       className={clsx(
         'group relative overflow-hidden rounded-lg bg-[#1a1a1a] transition-all cursor-pointer select-none',
         isDetailOpen
           ? 'ring-2 ring-sky-400'
           : selected
             ? 'ring-2 ring-indigo-500'
-            : 'hover:ring-1 hover:ring-indigo-500/50'
+            : 'hover:ring-1 hover:ring-indigo-500/50',
+        showVoteBorder && voteBorderClass(voteError, isLiked)
       )}
       onContextMenu={onContextMenu}
       onClick={onSelect}

+ 20 - 0
src/renderer/src/hooks/useVoteBorders.ts

@@ -0,0 +1,20 @@
+import { useQuery } from '@tanstack/react-query'
+
+export function useVoteBorders(): { enabled: boolean; failed: Record<string, string> } {
+  const { data: config } = useQuery({
+    queryKey: ['config'],
+    queryFn: () => window.electronAPI.config.get()
+  })
+  const { data: failed } = useQuery({
+    queryKey: ['steam-failed-votes'],
+    queryFn: () => window.electronAPI.steam.getFailedVotes()
+  })
+  return { enabled: config?.voteBorders ?? true, failed: failed ?? {} }
+}
+
+// an outline sits outside the selection ring, so both stay visible on a selected card
+export function voteBorderClass(voteError: string | undefined, isLiked: boolean): string | false {
+  if (voteError) return 'outline-2 outline-offset-2 outline-red-500'
+  if (isLiked) return 'outline-2 outline-offset-2 outline-green-500/70'
+  return false
+}

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

@@ -15,6 +15,7 @@ export const IpcChannels = {
   STEAM_VOTE: 'steam:vote',
   STEAM_OPEN_WORKSHOP: 'steam:open-workshop',
   STEAM_GET_VOTED_IDS: 'steam:get-voted-ids',
+  STEAM_GET_FAILED_VOTES: 'steam:get-failed-votes',
   STEAM_CHECK_VOTE: 'steam:check-vote',
   STEAM_REDOWNLOAD: 'steam:redownload',
   STEAM_GET_AUTHOR_INFO: 'steam:get-author-info',
@@ -114,6 +115,7 @@ export const IpcChannels = {
   // Tray & autostart
   CONFIG_SET_TRAY_ENABLED: 'config:set-tray-enabled',
   CONFIG_SET_KILL_LWE_ON_QUIT: 'config:set-kill-lwe-on-quit',
+  CONFIG_SET_VOTE_BORDERS: 'config:set-vote-borders',
   CONFIG_SET_AUTOSTART: 'config:set-autostart',
   CONFIG_GET_AUTOSTART_SUPPORTED: 'config:get-autostart-supported',
 
@@ -152,6 +154,7 @@ export const IpcChannels = {
   EVENT_BACKUP_PROGRESS: 'event:backup-progress',
   EVENT_PLAYLIST_STATE_CHANGED: 'event:playlist-state-changed',
   EVENT_VOTED_IDS_CHANGED: 'event:voted-ids-changed',
+  EVENT_FAILED_VOTES_CHANGED: 'event:failed-votes-changed',
   EVENT_LIBRARY_CHANGED: 'event:library-changed',
   EVENT_DISPLAY_CHANGED: 'event:display-changed',
   EVENT_WALLPAPER_CRASHED: 'event:wallpaper-crashed',