Parcourir la source

fixed steam video recording, implemented the behaviour of original wallpaper engine, where it only plays when not minimized

UwU il y a 8 heures
Parent
commit
1c03f3b31c

+ 8 - 0
electron.vite.config.ts

@@ -6,6 +6,14 @@ import tailwindcss from '@tailwindcss/vite'
 export default defineConfig({
   main: {
     plugins: [externalizeDepsPlugin()],
+    build: {
+      rollupOptions: {
+        input: {
+          index: resolve('src/main/index.ts'),
+          'steam-host': resolve('src/main/steam-host.ts')
+        }
+      }
+    },
     resolve: {
       alias: {
         '@shared': resolve('src/shared')

+ 8 - 6
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, onFailedVotesChange } from './services/steam.service'
+import { isSteamRunning, startVotedItemsSync, onFailedVotesChange, bindSteamToWindow } from './services/steam.service'
 import { initLibrary, checkUnavailableWallpapers, backfillResolutions } from './services/library.service'
 import { startWatcher } from './services/watcher.service'
 import { registerAllHandlers } from './ipc'
@@ -38,6 +38,12 @@ function startBackgroundSync(win: BrowserWindow): void {
   }
   win.webContents.once('did-finish-load', () => void syncResolutions())
 
+  const pushSteamStatus = (): void => {
+    if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_STEAM_STATUS, isSteamRunning())
+  }
+  win.on('show', pushSteamStatus)
+  win.on('restore', pushSteamStatus)
+
   setInterval(async () => {
     if (win.isDestroyed()) return
     const running = isSteamRunning()
@@ -106,14 +112,10 @@ app.whenReady().then(() => {
     }
   })
 
-  const steamOk = initSteam()
-  if (!steamOk) {
-    console.warn('[App] Steam unavailable, subscription features disabled')
-  }
-
   initLibrary()
 
   const win = createWindow()
+  bindSteamToWindow(win)
   registerAllHandlers(win)
   startWatcher(win)
   startBackgroundSync(win)

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

@@ -8,17 +8,24 @@ import { setDependencyInstaller } from '../services/dependency.service'
 
 function startDownloadPoll(win: BrowserWindow, itemId: string): void {
   const idBig = BigInt(itemId)
-  const pollInterval = setInterval(() => {
+  let polling = false
+  const pollInterval = setInterval(async () => {
     if (win.isDestroyed()) {
       clearInterval(pollInterval)
       return
     }
-    const info = steam.getDownloadInfo(idBig)
-    if (info) {
-      win.webContents.send(IpcChannels.EVENT_DOWNLOAD_PROGRESS, info)
-      return
+    if (polling || !steam.isSteamAllowed()) return
+    polling = true
+    try {
+      const info = await steam.getDownloadInfo(idBig)
+      if (info) {
+        if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_DOWNLOAD_PROGRESS, info)
+        return
+      }
+      if (await steam.isItemDownloading(idBig)) return // queued/pending, no progress numbers yet
+    } finally {
+      polling = false
     }
-    if (steam.isItemDownloading(idBig)) return // queued/pending, no progress numbers yet
 
     clearInterval(pollInterval)
     library.importWallpaperById(itemId).then((meta) => {
@@ -38,7 +45,7 @@ function startDownloadPoll(win: BrowserWindow, itemId: string): void {
 export function registerSteamHandlers(win: BrowserWindow): void {
   setDependencyInstaller(async (itemId) => {
     await steam.subscribeToItem(BigInt(itemId))
-    steam.downloadItem(BigInt(itemId))
+    await steam.downloadItem(BigInt(itemId))
     startDownloadPoll(win, itemId)
   })
 
@@ -50,7 +57,7 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     await steam.subscribeToItem(BigInt(itemId))
     // Steam doesn't always actually start fetching content just because we subscribed - see
     // downloadItem()'s comment. This is the reliable kick.
-    steam.downloadItem(BigInt(itemId))
+    await steam.downloadItem(BigInt(itemId))
     startDownloadPoll(win, itemId)
     return { ok: true }
   })
@@ -104,8 +111,8 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     return { ok: true, confirmed }
   })
 
-  ipcMain.handle(IpcChannels.STEAM_OPEN_WORKSHOP, (_e, itemId: string) => {
-    steam.openWorkshopItemOverlay(BigInt(itemId))
+  ipcMain.handle(IpcChannels.STEAM_OPEN_WORKSHOP, async (_e, itemId: string) => {
+    await steam.openWorkshopItemOverlay(BigInt(itemId))
     return { ok: true }
   })
 

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

@@ -147,10 +147,7 @@ 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.
- *  Only read once at startup (steam.service.ts initSteam()) - changing it takes effect on relaunch. */
+/** 'wallpaper-engine' connects under WE's app id, 'standalone' under a neutral test app id. Read on every connect. */
 export function getSteamIdentity(): SteamIdentity {
   return store.get('steamIdentity')
 }

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

@@ -12,7 +12,7 @@ import {
   getUnavailableWorkshopItems
 } from './workshop.service'
 import { findAndMutate } from '../utils/collections'
-import { RESOLUTION_TAGS } from '@shared/resolutions'
+import { resolutionsFromTags } from '@shared/resolutions'
 import {
   isSteamRunning,
   getItemDownloadStatus,
@@ -25,6 +25,7 @@ interface LibraryStore {
   wallpapers: Record<string, WallpaperMeta>
   tags: string[]
   folders: WallpaperFolder[]
+  resolutionsVersion: number
 }
 
 const store = new Store<LibraryStore>({
@@ -32,7 +33,8 @@ const store = new Store<LibraryStore>({
   defaults: {
     wallpapers: {},
     tags: [],
-    folders: []
+    folders: [],
+    resolutionsVersion: 0
   }
 })
 
@@ -251,10 +253,6 @@ function sameTags(a: string[] | undefined, b: string[] | undefined): boolean {
   return a.length === b.length && a.every((t) => b.includes(t))
 }
 
-function deriveResolutionsFromTags(tags: string[]): string[] {
-  return tags.filter((t) => RESOLUTION_TAGS.has(t))
-}
-
 const RATING_RESTRICTIVENESS: Record<ContentRating, number> = {
   uncategorized: 0,
   everyone: 1,
@@ -349,21 +347,21 @@ function buildMeta(
     source: 'workshop',
     backupDir: existing?.backupDir ?? (existing?.source === 'backup' ? existing.localPath : undefined),
     tags: pj?.tags ?? existing?.tags ?? [],
-    resolutions: liveWorkshopTags ? deriveResolutionsFromTags(liveWorkshopTags) : existing?.resolutions,
+    resolutions: liveWorkshopTags ? resolutionsFromTags(liveWorkshopTags) : existing?.resolutions,
     authorSteamId: authorSteamId ?? existing?.authorSteamId
   }
 }
 
-function buildIncompleteMeta(
+async function buildIncompleteMeta(
   workshopId: string,
   localPath: string,
   existing?: WallpaperMeta,
   workshopTags?: string[],
   authorSteamId?: string
-): WallpaperMeta {
+): Promise<WallpaperMeta> {
   const now = Date.now()
   const { downloading, failed } = isSteamRunning()
-    ? getItemDownloadStatus(BigInt(workshopId))
+    ? await getItemDownloadStatus(BigInt(workshopId))
     : { downloading: true, failed: false }
   const tags = workshopTags
     ? [...new Set([...(existing?.tags ?? []), ...workshopTags])]
@@ -381,7 +379,7 @@ function buildIncompleteMeta(
     appliedCount: existing?.appliedCount ?? 0,
     source: 'workshop',
     tags,
-    resolutions: workshopTags ? deriveResolutionsFromTags(workshopTags) : existing?.resolutions,
+    resolutions: workshopTags ? resolutionsFromTags(workshopTags) : existing?.resolutions,
     categories: existing?.categories ?? [],
     downloading,
     downloadFailed: failed,
@@ -401,7 +399,7 @@ export async function importWallpaperById(workshopId: string): Promise<Wallpaper
   const pj = readProjectJson(localPath)
   if (!pj) {
     const tags = await safeGetWorkshopTags([workshopId])
-    const meta = buildIncompleteMeta(workshopId, localPath, existing, tags.get(workshopId), authorSteamId)
+    const meta = await buildIncompleteMeta(workshopId, localPath, existing, tags.get(workshopId), authorSteamId)
     upsertWallpaper(meta)
     return meta
   }
@@ -427,7 +425,9 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
 
   const entries = fs.readdirSync(workshopPath, { withFileTypes: true })
   const onDisk = new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
-  const subscribedIds = isSteamRunning() ? new Set(getSubscribedItems()) : new Set<string>()
+  const subscribedIds = isSteamRunning()
+    ? new Set(await getSubscribedItems().catch(() => []))
+    : new Set<string>()
 
   // Load entire store once, mutate in memory, write once at the end
   const wallpapers = store.get('wallpapers')
@@ -478,8 +478,8 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
   // isItemStuckNeverDownloaded, this is more common than it sounds).
   for (const id of subscribedIds) {
     if (onDisk.has(id)) continue
-    if (isSteamRunning() && isItemStuckNeverDownloaded(BigInt(id))) {
-      downloadItem(BigInt(id))
+    if (isSteamRunning() && (await isItemStuckNeverDownloaded(BigInt(id)))) {
+      await downloadItem(BigInt(id))
     }
     incomplete.push({ id, localPath: path.join(workshopPath, id) })
   }
@@ -495,7 +495,7 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
   const incompleteTags = await safeGetWorkshopTags(incomplete.map((i) => i.id))
   for (const { id, localPath } of incomplete) {
     const existing = wallpapers[id]
-    const meta = buildIncompleteMeta(id, localPath, existing, incompleteTags.get(id), authors.get(id))
+    const meta = await buildIncompleteMeta(id, localPath, existing, incompleteTags.get(id), authors.get(id))
     if (!existing) {
       wallpapers[id] = meta
       imported++
@@ -589,7 +589,7 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
       }
     }
     if (live) {
-      const resolutions = deriveResolutionsFromTags(live)
+      const resolutions = resolutionsFromTags(live)
       if (!sameTags(wallpapers[id].resolutions, resolutions)) {
         patch.resolutions = resolutions
       }
@@ -652,27 +652,46 @@ export async function checkUnavailableWallpapers(): Promise<{
   return { checked: candidates.length, unavailable: unavailableIds.size, changed }
 }
 
+// Bump whenever resolutionsFromTags() changes, stored resolutions then get re-derived once
+const RESOLUTIONS_VERSION = 2
+
 // Resolution only comes from the live Workshop tags (project.json never has it), so items
 // imported before that was stored, or while Steam was offline, need a separate pass.
 export async function backfillResolutions(): Promise<boolean> {
   if (!isSteamRunning()) return false
 
-  const wallpapers = store.get('wallpapers')
-  const missing = Object.values(wallpapers).filter(
-    (w) => w.source === 'workshop' && w.resolutions === undefined
-  )
-  if (missing.length === 0) return false
+  const rederive = store.get('resolutionsVersion') !== RESOLUTIONS_VERSION
+  const ids = Object.values(store.get('wallpapers'))
+    .filter((w) => w.source === 'workshop' && (rederive || w.resolutions === undefined))
+    .map((w) => w.id)
+
+  let tags = new Map<string, string[]>()
+  if (ids.length > 0) {
+    try {
+      tags = await getWorkshopTags(ids)
+    } catch {
+      return false
+    }
+  }
 
-  const tags = await safeGetWorkshopTags(missing.map((w) => w.id))
+  // re-read, the fetch takes a while and other writes may have landed meanwhile
+  const wallpapers = store.get('wallpapers')
   let changed = false
-  for (const w of missing) {
-    const live = tags.get(w.id)
-    if (!live) continue
-    wallpapers[w.id] = { ...w, resolutions: deriveResolutionsFromTags(live) }
+  for (const id of ids) {
+    const w = wallpapers[id]
+    if (!w) continue
+    const live = tags.get(id)
+    // Steam no longer returns removed or private items, record "none" instead of asking every tick
+    const resolutions = live ? resolutionsFromTags(live) : (w.resolutions ?? [])
+    if (sameTags(w.resolutions, resolutions)) continue
+    wallpapers[id] = { ...w, resolutions }
     changed = true
   }
-  console.log(`[Library] Resolution tags fetched for ${tags.size} of ${missing.length} items`)
   if (changed) store.set('wallpapers', wallpapers)
+  store.set('resolutionsVersion', RESOLUTIONS_VERSION)
+  if (ids.length > 0) {
+    console.log(`[Library] Resolution tags fetched for ${tags.size} of ${ids.length} items`)
+  }
   return changed
 }
 

+ 236 - 151
src/main/services/steam.service.ts

@@ -1,138 +1,227 @@
+import * as fs from 'fs'
+import * as os from 'os'
 import * as path from 'path'
 import Store from 'electron-store'
-import { app } from 'electron'
+import { app, utilityProcess, type BrowserWindow, type UtilityProcess } from 'electron'
 import { WE_APP_ID, STANDALONE_APP_ID } from '@shared/constants'
 import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
 import { getSteamIdentity } from './config.service'
+import { encodeBigInts, decodeBigInts, type SteamHostResponse } from '../steam-host-protocol'
 
-let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
+// Steam counts a process that called SteamAPI_Init as a running game, which also breaks Steam
+// Game Recording. Like WE's ui32.exe, the connection only lives while the window is shown.
+let windowActive = false
 
 // Steam not running is a normal, common state (this app works as a local library manager
-// without it). Every isSteamRunning()/getClient() call used to retry the native init and log a
-// warning, so anything that polls (react-query refetches, library scans, ...) turned "Steam is
-// closed" into a continuous stream of console warnings. Only actually retry - and only warn -
-// once per cooldown window, and only log the first failure of a run.
+// without it). Only retry a failed connect - and only warn - once per cooldown window, and only
+// log the first failure of a run.
 const RETRY_COOLDOWN_MS = 15000
-let lastInitAttempt = 0
+let lastInitFailure = 0
 let hasWarnedThisOutage = false
 
-export function initSteam(): boolean {
-  lastInitAttempt = Date.now()
-  try {
-    // steamworks.js is a native module and must be required at runtime
-    // eslint-disable-next-line @typescript-eslint/no-require-imports
-    const steamworks: typeof import('steamworks.js') = require('steamworks.js')
-    const appId = getSteamIdentity() === 'standalone' ? STANDALONE_APP_ID : WE_APP_ID
-    client = steamworks.init(appId)
-    hasWarnedThisOutage = false
-    console.log(`[Steam] Initialized (app id ${appId})`)
-    return true
-  } catch (err) {
-    client = null
-    if (!hasWarnedThisOutage) {
-      hasWarnedThisOutage = true
-      console.warn('[Steam] Init failed (Steam may not be running):', err)
+let host: UtilityProcess | null = null
+let hostReady: Promise<void> | null = null
+let nextRequestId = 1
+const pendingRequests = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
+let inFlight = 0
+
+const STEAM_PID_FILES = [
+  path.join(os.homedir(), '.steam', 'steam.pid'),
+  path.join(os.homedir(), '.var', 'app', 'com.valvesoftware.Steam', '.steam', 'steam.pid')
+]
+const PROCESS_CHECK_TTL_MS = 5000
+let processCheck = { at: 0, alive: false }
+
+// Flatpak Steam writes a pid from its own namespace, hence the /proc scan fallback
+function steamProcessAlive(): boolean {
+  if (Date.now() - processCheck.at < PROCESS_CHECK_TTL_MS) return processCheck.alive
+  let alive = false
+  for (const file of STEAM_PID_FILES) {
+    try {
+      const pid = Number(fs.readFileSync(file, 'utf8').trim())
+      if (pid > 0 && fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim() === 'steam') {
+        alive = true
+        break
+      }
+    } catch {
+      // no pid file or the process is gone
+    }
+  }
+  if (!alive) {
+    try {
+      alive = fs.readdirSync('/proc').some((entry) => {
+        if (!/^\d+$/.test(entry)) return false
+        try {
+          return fs.readFileSync(`/proc/${entry}/comm`, 'utf8').trim() === 'steam'
+        } catch {
+          return false
+        }
+      })
+    } catch {
+      alive = false
+    }
+  }
+  processCheck = { at: Date.now(), alive }
+  return alive
+}
+
+function stopHost(): void {
+  host?.kill()
+  host = null
+  hostReady = null
+}
+
+app.on('will-quit', stopHost)
+
+export function bindSteamToWindow(win: BrowserWindow): void {
+  const update = (): void => {
+    const active = !win.isDestroyed() && win.isVisible() && !win.isMinimized()
+    if (active === windowActive) return
+    windowActive = active
+    // calls already running get to finish, steamCall() disconnects after the last one
+    if (!active && host && inFlight === 0) {
+      console.log('[Steam] Window hidden, disconnecting')
+      stopHost()
     }
-    return false
   }
+  win.on('show', update)
+  win.on('hide', update)
+  win.on('minimize', update)
+  win.on('restore', update)
+  win.on('closed', update)
+  update()
+}
+
+export function isSteamAllowed(): boolean {
+  return windowActive
+}
+
+// steamworks.js async calls have no timeout of their own, a hung one would otherwise keep the
+// connection open forever
+const REQUEST_TIMEOUT_MS = 60 * 1000
+
+function send(proc: UtilityProcess, method: string, args: unknown[]): Promise<unknown> {
+  const id = nextRequestId++
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => {
+      pendingRequests.delete(id)
+      reject(new Error(`Steam ${method} timed out`))
+    }, REQUEST_TIMEOUT_MS)
+    pendingRequests.set(id, {
+      resolve: (v) => {
+        clearTimeout(timer)
+        resolve(v)
+      },
+      reject: (e) => {
+        clearTimeout(timer)
+        reject(e)
+      }
+    })
+    proc.postMessage(encodeBigInts({ id, method, args }))
+  })
+}
+
+function startHost(): Promise<void> {
+  const proc = utilityProcess.fork(path.join(__dirname, 'steam-host.js'), [], {
+    serviceName: 'we-manager-steam',
+    stdio: 'inherit'
+  })
+  host = proc
+  proc.on('message', (data) => {
+    const res = decodeBigInts(data) as SteamHostResponse
+    const pending = pendingRequests.get(res.id)
+    if (!pending) return
+    pendingRequests.delete(res.id)
+    if (res.error !== undefined) pending.reject(new Error(res.error))
+    else pending.resolve(res.result)
+  })
+  proc.on('exit', (code) => {
+    if (host === proc) {
+      host = null
+      hostReady = null
+    }
+    if (pendingRequests.size > 0) {
+      const err = new Error(`Steam host exited (code ${code})`)
+      for (const pending of pendingRequests.values()) pending.reject(err)
+      pendingRequests.clear()
+    }
+  })
+
+  const appId = getSteamIdentity() === 'standalone' ? STANDALONE_APP_ID : WE_APP_ID
+  return send(proc, 'init', [appId]).then(
+    () => {
+      hasWarnedThisOutage = false
+      console.log(`[Steam] Connected (app id ${appId})`)
+    },
+    (err) => {
+      if (host === proc) stopHost()
+      lastInitFailure = Date.now()
+      if (!hasWarnedThisOutage) {
+        hasWarnedThisOutage = true
+        console.warn('[Steam] Init failed (Steam may not be running):', err?.message ?? err)
+      }
+      throw err
+    }
+  )
 }
 
-function ensureClient(): void {
-  if (client) return
-  if (Date.now() - lastInitAttempt < RETRY_COOLDOWN_MS) return
-  initSteam()
+async function steamCall<T>(method: string, ...args: unknown[]): Promise<T> {
+  if (!windowActive) throw new Error('Steam is only used while the window is open')
+  if (!isSteamRunning()) throw new Error('Steam not running')
+  inFlight++
+  try {
+    if (!hostReady) hostReady = startHost()
+    await hostReady
+    return (await send(host!, method, args)) as T
+  } finally {
+    inFlight--
+    if (inFlight === 0 && host && !windowActive) {
+      console.log('[Steam] Window hidden, disconnecting')
+      stopHost()
+    }
+  }
 }
 
 export function isSteamRunning(): boolean {
-  ensureClient()
-  return client !== null
+  if (!windowActive) return false
+  if (Date.now() - lastInitFailure < RETRY_COOLDOWN_MS) return false
+  return steamProcessAlive()
+}
+
+export async function getLocalAccountId(): Promise<number> {
+  const id = await steamCall<{ accountId: number }>('localplayer.getSteamId')
+  return id.accountId
+}
+
+export function getSubscribedIds(): Promise<bigint[]> {
+  return steamCall<bigint[]>('workshop.getSubscribedItems')
 }
 
-export function getClient() {
-  ensureClient()
-  if (!client) throw new Error('Steam not initialized')
-  return client
+export function workshopCall<T>(method: string, ...args: unknown[]): Promise<T> {
+  return steamCall<T>(`workshop.${method}`, ...args)
 }
 
 export async function subscribeToItem(itemId: bigint): Promise<void> {
-  await getClient().workshop.subscribe(itemId)
+  await steamCall('workshop.subscribe', itemId)
 }
 
 // Steam's subscribe doesn't reliably auto-download (seen with items subscribed via the Workshop
 // website rather than the Steam client) - items can sit at bare "Subscribed" forever. This is the
 // reliable kick; a no-op if already installed or downloading.
-export function downloadItem(itemId: bigint, highPriority = true): boolean {
+export async function downloadItem(itemId: bigint, highPriority = true): Promise<boolean> {
   try {
-    return getClient().workshop.download(itemId, highPriority)
+    return await steamCall<boolean>('workshop.download', itemId, highPriority)
   } catch {
     return false
   }
 }
 
 export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
-  await getClient().workshop.unsubscribe(itemId)
-}
-
-// 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. Set up lazily
-// after SteamAPI_Init so a load failure can't take down the whole app.
-// eslint-disable-next-line @typescript-eslint/no-require-imports
-type Koffi = typeof import('koffi')
-
-let steamLibHandle: ReturnType<Koffi['load']> | null = null
-
-function getSteamLib(): ReturnType<Koffi['load']> {
-  if (!steamLibHandle) {
-    // eslint-disable-next-line @typescript-eslint/no-require-imports
-    const koffi: Koffi = require('koffi')
-    const libPath = path.join(
-      path.dirname(require.resolve('steamworks.js')),
-      'dist', 'linux64', 'libsteam_api.so'
-    ).replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked')
-    steamLibHandle = koffi.load(libPath)
-  }
-  return steamLibHandle
+  await steamCall('workshop.unsubscribe', itemId)
 }
 
-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: Koffi = require('koffi')
-    const steamLib = getSteamLib()
-    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
-}
-
-let isLoggedOnFn: (() => boolean) | null = null
-
-function isSteamOnline(): boolean {
-  if (!isLoggedOnFn) {
-    // eslint-disable-next-line @typescript-eslint/no-require-imports
-    const koffi: Koffi = require('koffi')
-    const steamLib = getSteamLib()
-    const userPtrType = koffi.pointer(koffi.opaque('ISteamUser'))
-    const getSteamUser = steamLib.func('SteamAPI_SteamUser_v023', userPtrType, [])
-    const bLoggedOn = steamLib.func('SteamAPI_ISteamUser_BLoggedOn', 'bool', [userPtrType])
-    const userPtr = getSteamUser()
-    isLoggedOnFn = () => bLoggedOn(userPtr)
-  }
-  return isLoggedOnFn()
+function isSteamOnline(): Promise<boolean> {
+  return steamCall<boolean>('isOnline')
 }
 
 function sleep(ms: number): Promise<void> {
@@ -157,41 +246,33 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
 }
 
 // steamworks.js has no vote bindings; this app's fork adds them (patches/steamworks-vote/), feature-detected so unpatched builds still work
-interface PatchedVoteWorkshop {
-  voteItem(itemId: bigint, voteUp: boolean): Promise<void>
-  getUserVote(itemId: bigint): Promise<{ votedUp: boolean; votedDown: boolean; voteSkipped: boolean }>
-}
-
-function getPatchedVoteApi(client: ReturnType<typeof getClient>): PatchedVoteWorkshop | null {
-  const workshop = client.workshop as unknown as Partial<PatchedVoteWorkshop>
-  if (typeof workshop.voteItem === 'function' && typeof workshop.getUserVote === 'function') {
-    return workshop as PatchedVoteWorkshop
+let hasPatchedVoteApi: boolean | null = null
+
+async function patchedVoteApiAvailable(): Promise<boolean> {
+  if (hasPatchedVoteApi === null) {
+    const [vote, check] = await Promise.all([
+      steamCall<boolean>('hasMethod', 'workshop.voteItem'),
+      steamCall<boolean>('hasMethod', 'workshop.getUserVote')
+    ])
+    hasPatchedVoteApi = vote && check
   }
-  return null
+  return hasPatchedVoteApi
 }
 
 export async function checkUserVote(
   itemId: bigint
 ): Promise<{ votedUp: boolean; votedDown: boolean } | null> {
-  const patched = getPatchedVoteApi(getClient())
-  if (!patched) return null
-  const result = await patched.getUserVote(itemId)
+  if (!(await patchedVoteApiAvailable())) return null
+  const result = await steamCall<{ votedUp: boolean; votedDown: boolean }>('workshop.getUserVote', itemId)
   return { votedUp: result.votedUp, votedDown: result.votedDown }
 }
 
-export function voteOnItem(itemId: bigint, voteUp: boolean): bigint {
-  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')
-  return handle
+export function voteOnItem(itemId: bigint, voteUp: boolean): Promise<bigint> {
+  return steamCall<bigint>('voteRaw', itemId, voteUp)
 }
 
-export function openWorkshopItemOverlay(itemId: bigint): void {
-  getClient().overlay.activateToWebPage(
-    `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
-  )
+export async function openWorkshopItemOverlay(itemId: bigint): Promise<void> {
+  await steamCall('overlay.activateToWebPage', `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`)
 }
 
 const USER_ITEM_TYPE = 13 // UGCType.All, since WE items span multiple subtypes
@@ -201,18 +282,23 @@ const VOTED_DOWN = 3
 const PAGE_FETCH_TIMEOUT_MS = 8000
 const VOTE_TIMEOUT_MS = 15000
 
+interface UserItemsPage {
+  items: Array<{ publishedFileId: bigint } | null | undefined>
+  returnedResults: number
+  totalResults: number
+}
+
 async function getUserItemsPage(
   accountId: number,
   listType: number,
   page: number,
   retries = 2
-): Promise<{ items: Array<{ publishedFileId: bigint } | null | undefined>; returnedResults: number; totalResults: number }> {
-  const c = getClient()
+): Promise<UserItemsPage> {
   for (let attempt = 0; ; attempt++) {
     try {
       return await withTimeout(
-        c.workshop.getUserItems(
-          page, accountId, listType, USER_ITEM_TYPE, CREATION_ORDER_DESC, { consumer: WE_APP_ID }
+        workshopCall<UserItemsPage>(
+          'getUserItems', page, accountId, listType, USER_ITEM_TYPE, CREATION_ORDER_DESC, { consumer: WE_APP_ID }
         ),
         PAGE_FETCH_TIMEOUT_MS,
         `getUserItems page ${page}`
@@ -342,7 +428,7 @@ function notifyVotedIds(): void {
 }
 
 async function runFullSync(): Promise<void> {
-  const accountId = getClient().localplayer.getSteamId().accountId
+  const accountId = await getLocalAccountId()
   if (voteStore.get('accountId') !== null && voteStore.get('accountId') !== accountId) {
     votedUpCache = new Set()
     missingLastCycle = new Set()
@@ -455,7 +541,7 @@ async function voteWithRetry(itemId: bigint, voteUp: boolean): Promise<boolean>
   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')
+    if (!(await 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)
@@ -477,12 +563,10 @@ async function voteWithRetry(itemId: bigint, voteUp: boolean): Promise<boolean>
 // because steamworks.js's manual dispatch loop consumes every call result on the shared Steam pipe first.
 async function voteOnce(itemId: bigint, voteUp: boolean): Promise<boolean> {
   const idStr = itemId.toString()
-  const patched = getPatchedVoteApi(getClient())
-
-  if (patched) {
-    await withTimeout(patched.voteItem(itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote')
+  if (await patchedVoteApiAvailable()) {
+    await withTimeout(steamCall('workshop.voteItem', itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote')
   } else {
-    voteOnItem(itemId, voteUp)
+    await voteOnItem(itemId, voteUp)
   }
   recordVote(idStr, voteUp)
   return true
@@ -502,7 +586,7 @@ export async function getVotedUpItemIds(): Promise<string[]> {
 const VOTE_SETTLE_MS = 10_000
 
 export async function refreshItemVote(itemId: bigint): Promise<boolean | null> {
-  if (!isSteamRunning() || !isSteamOnline()) return null
+  if (!isSteamRunning() || !(await isSteamOnline().catch(() => false))) return null
   const idStr = itemId.toString()
   const result = await withTimeout(checkUserVote(itemId), PAGE_FETCH_TIMEOUT_MS, 'getUserVote').catch((err) => {
     console.warn('[Steam] Per-item vote check failed:', err?.message ?? err)
@@ -533,15 +617,13 @@ export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
   voteSyncTimer = setInterval(maybeRunFullSync, SYNC_CHECK_INTERVAL_MS)
 }
 
-export function getSubscribedItems(): string[] {
-  return getClient()
-    .workshop.getSubscribedItems()
-    .map((id) => id.toString())
+export async function getSubscribedItems(): Promise<string[]> {
+  return (await getSubscribedIds()).map((id) => id.toString())
 }
 
-export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
+export async function getDownloadInfo(itemId: bigint): Promise<DownloadProgressEvent | null> {
   try {
-    const info = getClient().workshop.downloadInfo(itemId)
+    const info = await workshopCall<{ current: bigint; total: bigint } | null>('downloadInfo', itemId)
     if (!info) return null
     const bytesTotal = Number(info.total)
     const bytesDownloaded = Number(info.current)
@@ -557,9 +639,9 @@ export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
   }
 }
 
-export function getInstallInfo(itemId: bigint): { folder: string; sizeOnDisk: number } | null {
+export async function getInstallInfo(itemId: bigint): Promise<{ folder: string; sizeOnDisk: number } | null> {
   try {
-    const info = getClient().workshop.installInfo(itemId)
+    const info = await workshopCall<{ folder: string; sizeOnDisk: bigint } | null>('installInfo', itemId)
     if (!info) return null
     return { folder: info.folder, sizeOnDisk: Number(info.sizeOnDisk) }
   } catch {
@@ -587,9 +669,9 @@ export async function getAuthorInfo(steamId: string): Promise<WorkshopAuthorInfo
   }
 }
 
-export function getItemState(itemId: bigint): number {
+export async function getItemState(itemId: bigint): Promise<number> {
   try {
-    return getClient().workshop.state(itemId) as number
+    return await workshopCall<number>('state', itemId)
   } catch {
     return 0
   }
@@ -603,15 +685,18 @@ const ITEM_STATE_DOWNLOAD_PENDING = 32
 
 // True for a subscribed item Steam has never actually started fetching - not installed, not
 // mid-transfer, not even flagged as needing an update. See downloadItem() above.
-export function isItemStuckNeverDownloaded(itemId: bigint): boolean {
-  const state = getItemState(itemId)
+function isStuckNeverDownloaded(state: number): boolean {
   const installed = (state & ITEM_STATE_INSTALLED) !== 0
   const inProgress = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING | ITEM_STATE_NEEDS_UPDATE)) !== 0
   return !installed && !inProgress
 }
 
-export function isItemDownloading(itemId: bigint): boolean {
-  const state = getItemState(itemId)
+export async function isItemStuckNeverDownloaded(itemId: bigint): Promise<boolean> {
+  return isStuckNeverDownloaded(await getItemState(itemId))
+}
+
+export async function isItemDownloading(itemId: bigint): Promise<boolean> {
+  const state = await getItemState(itemId)
   return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
 }
 
@@ -619,10 +704,10 @@ export function isItemDownloading(itemId: bigint): boolean {
 // it's set but nothing is actively downloading/pending, Steam gave up on the transfer (e.g. it
 // errored out or the disk filled up). A never-downloaded item reports neither bit - treated as
 // "downloading" since the caller (scanLibrary) just kicked one off for it.
-export function getItemDownloadStatus(itemId: bigint): { downloading: boolean; failed: boolean } {
-  const state = getItemState(itemId)
+export async function getItemDownloadStatus(itemId: bigint): Promise<{ downloading: boolean; failed: boolean }> {
+  const state = await getItemState(itemId)
   const downloading =
-    (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0 || isItemStuckNeverDownloaded(itemId)
+    (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0 || isStuckNeverDownloaded(state)
   const failed = !downloading && (state & ITEM_STATE_NEEDS_UPDATE) !== 0
   return { downloading, failed }
 }

+ 17 - 21
src/main/services/workshop.service.ts

@@ -5,7 +5,11 @@ import type {
   WorkshopItem,
   CreatorWorkshopQueryParams
 } from '@shared/types'
-import { getClient } from './steam.service'
+import type { workshop } from 'steamworks.js/client'
+import { workshopCall, getSubscribedItems } from './steam.service'
+
+type SteamWorkshopItem = workshop.WorkshopItem
+type WorkshopPage = workshop.WorkshopPaginatedResult
 
 // Maps our string names to steamworks.js UGCQueryType enum values
 const QUERY_TYPE_MAP: Record<string, number> = {
@@ -30,12 +34,12 @@ const USER_LIST_ORDER_MAP: Record<string, number> = {
 }
 
 export async function queryWorkshop(params: WorkshopQueryParams): Promise<WorkshopQueryResult> {
-  const client = getClient()
   const appId = params.appId ?? WE_APP_ID
   const page = params.page ?? 1
   const queryType = QUERY_TYPE_MAP[params.queryType ?? 'RankedByPublicationDate'] ?? 1
 
-  const result = await client.workshop.getAllItems(
+  const result = await workshopCall<WorkshopPage>(
+    'getAllItems',
     page,
     queryType,
     13, // UGCType.All, since WE items span multiple subtypes
@@ -49,9 +53,7 @@ export async function queryWorkshop(params: WorkshopQueryParams): Promise<Worksh
     }
   )
 
-  const subscribedSet = new Set(
-    client.workshop.getSubscribedItems().map((id) => id.toString())
-  )
+  const subscribedSet = new Set(await getSubscribedItems())
 
   const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
   await attachFileSizes(items)
@@ -64,10 +66,9 @@ export async function queryWorkshop(params: WorkshopQueryParams): Promise<Worksh
 }
 
 export async function getWorkshopItem(publishedFileId: string): Promise<WorkshopItem | null> {
-  const client = getClient()
-  let item: Awaited<ReturnType<typeof client.workshop.getItem>>
+  let item: SteamWorkshopItem | null
   try {
-    item = await client.workshop.getItem(BigInt(publishedFileId), {
+    item = await workshopCall<SteamWorkshopItem | null>('getItem', BigInt(publishedFileId), {
       includeLongDescription: true
     })
   } catch {
@@ -78,9 +79,7 @@ export async function getWorkshopItem(publishedFileId: string): Promise<Workshop
   }
   if (!item) return null
 
-  const subscribedSet = new Set(
-    client.workshop.getSubscribedItems().map((id) => id.toString())
-  )
+  const subscribedSet = new Set(await getSubscribedItems())
   const transformed = transformItem(item, subscribedSet)
   await attachFileSizes([transformed])
   return transformed
@@ -92,12 +91,12 @@ export async function queryWorkshopByCreator(
   creatorSteamId: string,
   params: CreatorWorkshopQueryParams = {}
 ): Promise<WorkshopQueryResult> {
-  const client = getClient()
   const accountId = Number(BigInt(creatorSteamId) & 0xffffffffn)
   const page = params.page ?? 1
   const sortOrder = USER_LIST_ORDER_MAP[params.queryType ?? 'RankedByPublicationDate'] ?? 1
 
-  const result = await client.workshop.getUserItems(
+  const result = await workshopCall<WorkshopPage>(
+    'getUserItems',
     page,
     accountId,
     0, // UserListType.Published
@@ -112,9 +111,7 @@ export async function queryWorkshopByCreator(
     }
   )
 
-  const subscribedSet = new Set(
-    client.workshop.getSubscribedItems().map((id) => id.toString())
-  )
+  const subscribedSet = new Set(await getSubscribedItems())
 
   const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
   await attachFileSizes(items)
@@ -131,11 +128,10 @@ export async function queryWorkshopByCreator(
 const DETAILS_QUERY_BATCH = 50
 
 async function getItemsBatched(publishedFileIds: string[]) {
-  const client = getClient()
-  const items = []
+  const items: SteamWorkshopItem[] = []
   for (let i = 0; i < publishedFileIds.length; i += DETAILS_QUERY_BATCH) {
     const batch = publishedFileIds.slice(i, i + DETAILS_QUERY_BATCH)
-    const result = await client.workshop.getItems(batch.map((id) => BigInt(id)))
+    const result = await workshopCall<workshop.WorkshopItemsResult>('getItems', batch.map((id) => BigInt(id)))
     for (const item of result.items) {
       if (item) items.push(item)
     }
@@ -239,7 +235,7 @@ async function attachFileSizes(items: WorkshopItem[]): Promise<void> {
 }
 
 function transformItem(
-  item: NonNullable<Awaited<ReturnType<ReturnType<typeof getClient>['workshop']['getItem']>>>,
+  item: SteamWorkshopItem,
   subscribedSet: Set<string>
 ): WorkshopItem {
   return {

+ 19 - 0
src/main/steam-host-protocol.test.ts

@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { encodeBigInts, decodeBigInts } from './steam-host-protocol'
+
+describe('steam host bigint encoding', () => {
+  it('round-trips nested bigints', () => {
+    const value = {
+      items: [{ publishedFileId: 3806202923n, owner: { steamId64: 76561198000000000n } }, null],
+      totalResults: 2,
+      tags: ['Scene']
+    }
+    const wire = JSON.parse(JSON.stringify(encodeBigInts(value)))
+    expect(decodeBigInts(wire)).toEqual(value)
+  })
+
+  it('leaves plain objects with other keys alone', () => {
+    const value = { __bigint: '1', other: true }
+    expect(decodeBigInts(value)).toEqual(value)
+  })
+})

+ 37 - 0
src/main/steam-host-protocol.ts

@@ -0,0 +1,37 @@
+export interface SteamHostRequest {
+  id: number
+  method: string
+  args: unknown[]
+}
+
+export interface SteamHostResponse {
+  id: number
+  result?: unknown
+  error?: string
+}
+
+const BIGINT_TAG = '__bigint'
+
+// steamworks.js hands out bigints (item ids, steam ids), tagged so they survive the process hop
+export function encodeBigInts(value: unknown): unknown {
+  if (typeof value === 'bigint') return { [BIGINT_TAG]: value.toString() }
+  if (Array.isArray(value)) return value.map(encodeBigInts)
+  if (value && typeof value === 'object') {
+    const out: Record<string, unknown> = {}
+    for (const [k, v] of Object.entries(value)) out[k] = encodeBigInts(v)
+    return out
+  }
+  return value
+}
+
+export function decodeBigInts(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(decodeBigInts)
+  if (value && typeof value === 'object') {
+    const tagged = (value as Record<string, unknown>)[BIGINT_TAG]
+    if (typeof tagged === 'string' && Object.keys(value).length === 1) return BigInt(tagged)
+    const out: Record<string, unknown> = {}
+    for (const [k, v] of Object.entries(value)) out[k] = decodeBigInts(v)
+    return out
+  }
+  return value
+}

+ 90 - 0
src/main/steam-host.ts

@@ -0,0 +1,90 @@
+import * as path from 'path'
+import { encodeBigInts, decodeBigInts, type SteamHostRequest, type SteamHostResponse } from './steam-host-protocol'
+
+type Koffi = typeof import('koffi')
+type SteamClient = ReturnType<typeof import('steamworks.js')['init']>
+
+let client: SteamClient | null = null
+
+function init(appId: number): void {
+  // eslint-disable-next-line @typescript-eslint/no-require-imports
+  const steamworks: typeof import('steamworks.js') = require('steamworks.js')
+  client = steamworks.init(appId)
+}
+
+// ISteamUGC::SetUserItemVote and ISteamUser::BLoggedOn are not bound in steamworks.js, so they
+// go through the flat C API. libsteam_api.so is already loaded by steamworks.js, dlopen just
+// returns the existing handle.
+let steamLib: ReturnType<Koffi['load']> | null = null
+let setUserItemVote: ((ugc: unknown, itemId: bigint, voteUp: boolean) => bigint) | null = null
+let ugcPtr: unknown = null
+let isLoggedOn: (() => boolean) | null = null
+
+function getSteamLib(): ReturnType<Koffi['load']> {
+  if (!steamLib) {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const koffi: Koffi = require('koffi')
+    const libPath = path.join(
+      path.dirname(require.resolve('steamworks.js')),
+      'dist', 'linux64', 'libsteam_api.so'
+    ).replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked')
+    steamLib = koffi.load(libPath)
+  }
+  return steamLib
+}
+
+function voteRaw(itemId: bigint, voteUp: boolean): bigint {
+  if (!setUserItemVote) {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const koffi: Koffi = require('koffi')
+    const lib = getSteamLib()
+    const ugcPtrType = koffi.pointer(koffi.opaque('ISteamUGC'))
+    ugcPtr = lib.func('SteamAPI_SteamUGC_v020', ugcPtrType, [])()
+    setUserItemVote = lib.func('SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [ugcPtrType, 'uint64', 'bool'])
+  }
+  const handle = setUserItemVote(ugcPtr, itemId, voteUp)
+  // k_uAPICallInvalid = 0 means the call failed immediately
+  if (handle === 0n) throw new Error('SetUserItemVote failed')
+  return handle
+}
+
+function isOnline(): boolean {
+  if (!isLoggedOn) {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const koffi: Koffi = require('koffi')
+    const lib = getSteamLib()
+    const userPtrType = koffi.pointer(koffi.opaque('ISteamUser'))
+    const userPtr = lib.func('SteamAPI_SteamUser_v023', userPtrType, [])()
+    const bLoggedOn = lib.func('SteamAPI_ISteamUser_BLoggedOn', 'bool', [userPtrType])
+    isLoggedOn = () => bLoggedOn(userPtr)
+  }
+  return isLoggedOn()
+}
+
+async function call(method: string, args: unknown[]): Promise<unknown> {
+  if (method === 'init') {
+    init(args[0] as number)
+    return null
+  }
+  if (!client) throw new Error('Steam not initialized')
+  if (method === 'voteRaw') return voteRaw(args[0] as bigint, args[1] as boolean)
+  if (method === 'isOnline') return isOnline()
+  if (method === 'hasMethod') {
+    const [ns, fn] = (args[0] as string).split('.')
+    return typeof (client as unknown as Record<string, Record<string, unknown>>)[ns]?.[fn] === 'function'
+  }
+
+  const [ns, fn] = method.split('.')
+  const target = (client as unknown as Record<string, Record<string, unknown>>)[ns]
+  const func = target?.[fn]
+  if (typeof func !== 'function') throw new Error(`Unknown Steam method ${method}`)
+  return await func.apply(target, args)
+}
+
+process.parentPort.on('message', (e) => {
+  const req = decodeBigInts(e.data) as SteamHostRequest
+  call(req.method, req.args).then(
+    (result) => process.parentPort.postMessage(encodeBigInts({ id: req.id, result } satisfies SteamHostResponse)),
+    (err) => process.parentPort.postMessage({ id: req.id, error: (err as Error)?.message ?? String(err) } satisfies SteamHostResponse)
+  )
+})

+ 3 - 4
src/renderer/src/components/common/DetailSidebar.tsx

@@ -40,7 +40,7 @@ import {
 } from 'lucide-react'
 import clsx from 'clsx'
 import { openWorkshopPage, openProfilePage, isWorkshopId } from '../../utils/steam'
-import { WE_TYPES, WE_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
+import { WE_TYPES, WE_AGE_RATINGS, canonicalResolutionTag, resolutionsFromTags } from '../../constants/weFilters'
 import { formatFileSize } from '../../utils/format'
 import { useToast } from './Toast'
 import ScreenSelect from './ScreenSelect'
@@ -72,7 +72,6 @@ const SCALING_MODE_OPTIONS: { value: ScalingMode; label: string }[] = [
 
 const TYPE_TAGS = new Set(WE_TYPES.map((i) => i.tag))
 const AGE_TAGS = new Set(WE_AGE_RATINGS.map((i) => i.tag))
-const RESOLUTION_TAGS = new Set(WE_RESOLUTION_GROUPS.flatMap((g) => g.items.map((i) => i.tag)))
 
 function StarRating({ upvotes, downvotes }: { upvotes: number; downvotes: number }) {
   const total = upvotes + downvotes
@@ -854,9 +853,9 @@ export default function DetailSidebar({
 
   const typeTag = tags.find((t) => TYPE_TAGS.has(t))
   const ageTag = tags.find((t) => AGE_TAGS.has(t))
-  const resolutionTags = tags.filter((t) => RESOLUTION_TAGS.has(t))
+  const resolutionTags = resolutionsFromTags(tags)
   const genreTags = tags.filter(
-    (t) => t !== typeTag && t !== ageTag && !resolutionTags.includes(t)
+    (t) => t !== typeTag && t !== ageTag && !canonicalResolutionTag(t)
   )
   // Prefer the real on-disk size for an already-downloaded item over Steam's reported upload
   // size (item.fileSize) - they can differ once extracted, and the local number is exact.

+ 13 - 11
src/renderer/src/components/library/LibraryView.tsx

@@ -33,7 +33,14 @@ import DetailSidebar from '../common/DetailSidebar'
 import { useToast } from '../common/Toast'
 import type { LibraryFilters, WallpaperFolder, LweStatus, ScreenTarget } from '@shared/types'
 import clsx from 'clsx'
-import { WE_TYPES, WE_LIBRARY_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
+import {
+  WE_TYPES,
+  WE_LIBRARY_AGE_RATINGS,
+  WE_RESOLUTION_GROUPS,
+  isGroupSelected,
+  setGroupSelected,
+  migrateResolutionSelection
+} from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { useVoteBorders } from '../../hooks/useVoteBorders'
 import { useSelectableGrid } from '../../hooks/useSelectableGrid'
@@ -88,7 +95,9 @@ const SOURCE_MAP: Record<string, string> = {
 function loadFilters(): LibraryFilterState {
   try {
     const raw = localStorage.getItem(STORAGE_KEY)
-    return raw ? { ...DEFAULT_STATE, ...JSON.parse(raw) } : DEFAULT_STATE
+    if (!raw) return DEFAULT_STATE
+    const parsed = JSON.parse(raw)
+    return { ...DEFAULT_STATE, ...parsed, resolutions: migrateResolutionSelection(parsed.resolutions) }
   } catch {
     return DEFAULT_STATE
   }
@@ -249,21 +258,14 @@ function ResolutionDropdown({
         <div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
           <div className="max-h-80 overflow-y-auto">
             {WE_RESOLUTION_GROUPS.map((group) => {
-              const groupTags = group.items.map((i) => i.tag)
-              const allChecked = groupTags.every((t) => selected.includes(t))
+              const allChecked = isGroupSelected(selected, group)
               return (
                 <div key={group.label}>
                   <label className="flex cursor-pointer items-center gap-2 px-3 pb-1 pt-2 hover:bg-white/5">
                     <input
                       type="checkbox"
                       checked={allChecked}
-                      onChange={() =>
-                        onChange(
-                          allChecked
-                            ? selected.filter((t) => !groupTags.includes(t))
-                            : [...new Set([...selected, ...groupTags])]
-                        )
-                      }
+                      onChange={() => onChange(setGroupSelected(selected, group, !allChecked))}
                       className="accent-indigo-500"
                     />
                     <span className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">

+ 9 - 9
src/renderer/src/components/settings/SettingsView.tsx

@@ -1115,7 +1115,10 @@ export default function SettingsView() {
             Steam Integration
           </label>
           <p className="mt-1 text-xs text-gray-600">
-            How WE Manager identifies itself to Steam. Takes effect after restarting WE Manager.
+            How WE Manager identifies itself to Steam. Like Wallpaper Engine itself, it is only
+            connected while its window is open: hiding it to the tray or minimizing it
+            disconnects, so it no longer shows up as a running game or blocks Steam game
+            recording. Takes effect the next time the window is opened.
           </p>
 
           <div className="mt-3 flex rounded-lg overflow-hidden text-sm w-fit">
@@ -1143,21 +1146,18 @@ export default function SettingsView() {
             </button>
           </div>
           {steamIdentitySaved && (
-            <p className="mt-2 text-xs text-green-400">Saved - restart WE Manager for this to take effect.</p>
+            <p className="mt-2 text-xs text-green-400">Saved - used the next time the window is opened.</p>
           )}
           <p className="mt-2 text-xs text-gray-600">
             {steamIdentity === 'wallpaper-engine' ? (
               <>
-                Registers this process with Steam under Wallpaper Engine's own app id. Steam
-                considers WE Manager to be Wallpaper Engine itself, so it will offer to close it
-                when you log out or shut down Steam - the same as the official Windows app would
-                if you ran it standalone.
+                Connects to Steam under Wallpaper Engine's own app id. While connected, Steam
+                considers WE Manager to be Wallpaper Engine itself and shows you as playing it.
               </>
             ) : (
               <>
-                Registers this process under a neutral, unrelated Steam app id instead, so Steam
-                doesn't associate WE Manager with Wallpaper Engine and won't try to close it on
-                logout. Workshop browsing, subscribing and voting still target Wallpaper Engine's
+                Connects under a neutral, unrelated Steam app id instead, so Steam doesn't
+                associate WE Manager with Wallpaper Engine. Workshop browsing, subscribing and voting still target Wallpaper Engine's
                 own workshop either way. This mode is less tested than the default - if
                 subscribing or voting misbehaves, switch back.
               </>

+ 12 - 15
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -29,7 +29,10 @@ import {
   WE_ASSET_TYPES,
   WE_AGE_RATINGS,
   WE_RESOLUTION_GROUPS,
-  WE_GENRES
+  WE_GENRES,
+  ALL_RESOLUTION_TAGS,
+  setGroupSelected,
+  migrateResolutionSelection
 } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { useVoteBorders } from '../../hooks/useVoteBorders'
@@ -58,7 +61,6 @@ interface WorkshopFilterState {
   sizeFilterMb: number
 }
 
-const ALL_RESOLUTIONS = WE_RESOLUTION_GROUPS.flatMap((g) => g.items.map((i) => i.tag))
 const ALL_AGE_RATINGS = WE_AGE_RATINGS.map((i) => i.tag)
 const ALL_GENRES = WE_GENRES.map((i) => i.tag)
 const ALL_TYPES = WE_TYPES.map((i) => i.tag)
@@ -70,7 +72,7 @@ const DEFAULT_STATE: WorkshopFilterState = {
   types: ['Scene', 'Video', 'Web'],
   assetTypes: [],
   ageRatings: ALL_AGE_RATINGS,
-  resolutions: ALL_RESOLUTIONS,
+  resolutions: ALL_RESOLUTION_TAGS,
   genres: ALL_GENRES,
   notDownloaded: false,
   notLiked: false,
@@ -84,7 +86,9 @@ function loadFilters(): WorkshopFilterState {
     if (!raw) return DEFAULT_STATE
     const parsed = JSON.parse(raw)
     if (parsed._v !== STORAGE_VERSION) return DEFAULT_STATE
-    return { ...DEFAULT_STATE, ...parsed }
+    const state = { ...DEFAULT_STATE, ...parsed }
+    if ('resolutions' in parsed) state.resolutions = migrateResolutionSelection(parsed.resolutions)
+    return state
   } catch {
     return DEFAULT_STATE
   }
@@ -247,7 +251,7 @@ export default function WorkshopBrowser({
   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
-  const effectiveResolutions = filters.resolutions.length === ALL_RESOLUTIONS.length ? [] : filters.resolutions
+  const effectiveResolutions = filters.resolutions.length === ALL_RESOLUTION_TAGS.length ? [] : filters.resolutions
   const effectiveGenres = filters.genres.length === ALL_GENRES.length ? [] : filters.genres
 
   // AND mode: all selected tags passed to Steam as requiredTags (every tag must match)
@@ -835,11 +839,10 @@ export default function WorkshopBrowser({
               title="Resolution"
               defaultOpen={false}
               activeCount={effectiveResolutions.length}
-              onAll={() => setFilters((p) => ({ ...p, resolutions: ALL_RESOLUTIONS }))}
+              onAll={() => setFilters((p) => ({ ...p, resolutions: ALL_RESOLUTION_TAGS }))}
               onNone={() => setFilters((p) => ({ ...p, resolutions: [] }))}
             >
               {WE_RESOLUTION_GROUPS.map((group) => {
-                const groupTags = group.items.map((i) => i.tag)
                 return (
                   <div key={group.label} className="mt-2">
                     <div className="flex items-center justify-between mb-0.5 pl-0.5">
@@ -847,10 +850,7 @@ export default function WorkshopBrowser({
                       <div className="flex gap-1.5">
                         <button
                           onClick={() =>
-                            setFilters((p) => ({
-                              ...p,
-                              resolutions: [...new Set([...p.resolutions, ...groupTags])]
-                            }))
+                            setFilters((p) => ({ ...p, resolutions: setGroupSelected(p.resolutions, group, true) }))
                           }
                           className="text-[10px] text-gray-600 hover:text-gray-400"
                         >
@@ -858,10 +858,7 @@ export default function WorkshopBrowser({
                         </button>
                         <button
                           onClick={() =>
-                            setFilters((p) => ({
-                              ...p,
-                              resolutions: p.resolutions.filter((r) => !groupTags.includes(r))
-                            }))
+                            setFilters((p) => ({ ...p, resolutions: setGroupSelected(p.resolutions, group, false) }))
                           }
                           className="text-[10px] text-gray-600 hover:text-gray-400"
                         >

+ 10 - 1
src/renderer/src/constants/weFilters.ts

@@ -1,4 +1,13 @@
-export { WE_RESOLUTION_GROUPS, type ResolutionGroup } from '@shared/resolutions'
+export {
+  WE_RESOLUTION_GROUPS,
+  ALL_RESOLUTION_TAGS,
+  canonicalResolutionTag,
+  resolutionsFromTags,
+  isGroupSelected,
+  setGroupSelected,
+  migrateResolutionSelection,
+  type ResolutionGroup
+} from '@shared/resolutions'
 
 export interface FilterItem {
   tag: string

+ 42 - 0
src/shared/resolutions.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import {
+  ALL_RESOLUTION_TAGS,
+  WE_RESOLUTION_GROUPS,
+  resolutionsFromTags,
+  isGroupSelected,
+  setGroupSelected,
+  migrateResolutionSelection
+} from './resolutions'
+
+describe('resolutions', () => {
+  it('lists the 25 tags of WE', () => {
+    expect(ALL_RESOLUTION_TAGS).toHaveLength(25)
+    expect(new Set(ALL_RESOLUTION_TAGS).size).toBe(25)
+  })
+
+  it('picks resolution tags case-insensitively in WE spelling', () => {
+    expect(resolutionsFromTags(['Anime', 'Other Resolution', '1920 x 1080', 'dual standard definition', '1920 x 1080']))
+      .toEqual(['Other resolution', '1920 x 1080', 'Dual Standard Definition'])
+  })
+
+  it('toggles whole groups', () => {
+    const dual = WE_RESOLUTION_GROUPS.find((g) => g.label === 'Dual Monitor')!
+    const on = setGroupSelected(['1920 x 1080'], dual, true)
+    expect(isGroupSelected(on, dual)).toBe(true)
+    expect(setGroupSelected(on, dual, false)).toEqual(['1920 x 1080'])
+  })
+
+  it('migrates an old full selection to the full current list', () => {
+    const oldAll = [
+      'Standard Definition', '1280 x 720', '1366 x 768', '1920 x 1080', '2560 x 1440', '3840 x 2160',
+      'Ultrawide 2560 x 1080', 'Ultrawide 3440 x 1440', 'Ultrawide 3840 x 1600',
+      'Dual 3840 x 1080', 'Dual 5120 x 1440', 'Dual 7680 x 2160',
+      'Triple 4096 x 768', 'Triple 5760 x 1080', 'Triple 7680 x 1440', 'Triple 11520 x 2160',
+      'Portrait 720 x 1280', 'Portrait 1080 x 1920', 'Portrait 1440 x 2560', 'Portrait 2160 x 3840',
+      'Other Resolution', 'Dynamic Resolution'
+    ]
+    expect(migrateResolutionSelection(oldAll)).toEqual(ALL_RESOLUTION_TAGS)
+    expect(migrateResolutionSelection(['Other Resolution', 'Ultrawide 3840 x 1600'])).toEqual(['Other resolution'])
+    expect(migrateResolutionSelection(undefined)).toEqual([])
+  })
+})

+ 56 - 5
src/shared/resolutions.ts

@@ -3,6 +3,8 @@ export interface ResolutionGroup {
   items: { tag: string; label: string }[]
 }
 
+// Exactly the 25 tags of WE's own resolution filter (config.json steamuser.general.browser
+// .filterinfo.*.resolutiontags), including their casing ("Other resolution", not "Other Resolution")
 export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
   {
     label: 'Widescreen',
@@ -18,14 +20,15 @@ export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
   {
     label: 'Ultrawide',
     items: [
+      { tag: 'Ultrawide Standard Definition', label: 'Standard Definition' },
       { 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' }
+      { tag: 'Ultrawide 3440 x 1440', label: '3440 x 1440' }
     ]
   },
   {
     label: 'Dual Monitor',
     items: [
+      { tag: 'Dual Standard Definition', label: 'Standard Definition' },
       { 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' }
@@ -34,6 +37,7 @@ export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
   {
     label: 'Triple Monitor',
     items: [
+      { tag: 'Triple Standard Definition', label: 'Standard Definition' },
       { 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' },
@@ -43,6 +47,7 @@ export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
   {
     label: 'Portrait / Phone',
     items: [
+      { tag: 'Portrait Standard Definition', label: 'Standard Definition' },
       { 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' },
@@ -52,10 +57,56 @@ export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
   {
     label: 'Other',
     items: [
-      { tag: 'Other Resolution', label: 'Other Resolution' },
-      { tag: 'Dynamic Resolution', label: 'Dynamic Resolution' }
+      { tag: 'Other resolution', label: 'Other resolution' },
+      { tag: 'Dynamic resolution', label: 'Dynamic resolution' }
     ]
   }
 ]
 
-export const RESOLUTION_TAGS = new Set(WE_RESOLUTION_GROUPS.flatMap((g) => g.items.map((i) => i.tag)))
+export const ALL_RESOLUTION_TAGS: string[] = WE_RESOLUTION_GROUPS.flatMap((g) => g.items.map((i) => i.tag))
+
+// Workshop tags don't always match WE's casing, so lookups go through the lowercased tag
+const CANONICAL_TAG = new Map(ALL_RESOLUTION_TAGS.map((tag) => [tag.toLowerCase(), tag]))
+
+export function canonicalResolutionTag(tag: string): string | undefined {
+  return CANONICAL_TAG.get(tag.toLowerCase())
+}
+
+export function resolutionsFromTags(tags: readonly string[]): string[] {
+  const out: string[] = []
+  for (const tag of tags) {
+    const canonical = canonicalResolutionTag(tag)
+    if (canonical && !out.includes(canonical)) out.push(canonical)
+  }
+  return out
+}
+
+export function isGroupSelected(selected: readonly string[], group: ResolutionGroup): boolean {
+  return group.items.every((i) => selected.includes(i.tag))
+}
+
+export function setGroupSelected(selected: readonly string[], group: ResolutionGroup, on: boolean): string[] {
+  const tags = group.items.map((i) => i.tag)
+  return on ? [...new Set([...selected, ...tags])] : selected.filter((t) => !tags.includes(t))
+}
+
+// Missing from the list of older builds of this app. A saved selection holding every other tag
+// was "everything selected" back then.
+const ADDED_TAGS = new Set([
+  'Ultrawide Standard Definition',
+  'Dual Standard Definition',
+  'Triple Standard Definition',
+  'Portrait Standard Definition'
+])
+const PREVIOUS_ALL_TAGS = ALL_RESOLUTION_TAGS.filter((t) => !ADDED_TAGS.has(t))
+
+/** Maps a persisted selection onto the current tags: fixes casing, drops tags that no longer
+ *  exist, and turns an old "everything selected" into the current full list. */
+export function migrateResolutionSelection(saved: unknown): string[] {
+  if (!Array.isArray(saved)) return []
+  const tags = resolutionsFromTags(saved.filter((t): t is string => typeof t === 'string'))
+  if (tags.length < ALL_RESOLUTION_TAGS.length && PREVIOUS_ALL_TAGS.every((t) => tags.includes(t))) {
+    return [...ALL_RESOLUTION_TAGS]
+  }
+  return tags
+}