steam.service.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. import * as fs from 'fs'
  2. import * as os from 'os'
  3. import * as path from 'path'
  4. import Store from 'electron-store'
  5. import { app, utilityProcess, type BrowserWindow, type UtilityProcess } from 'electron'
  6. import { WE_APP_ID, STANDALONE_APP_ID } from '@shared/constants'
  7. import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
  8. import { getSteamIdentity } from './config.service'
  9. import { encodeBigInts, decodeBigInts, type SteamHostResponse } from '../steam-host-protocol'
  10. // Steam counts a process that called SteamAPI_Init as a running game, which also breaks Steam
  11. // Game Recording. Like WE's ui32.exe, the connection only lives while the window is shown.
  12. let windowActive = false
  13. // Steam not running is a normal, common state (this app works as a local library manager
  14. // without it). Only retry a failed connect - and only warn - once per cooldown window, and only
  15. // log the first failure of a run.
  16. const RETRY_COOLDOWN_MS = 15000
  17. let lastInitFailure = 0
  18. let hasWarnedThisOutage = false
  19. let host: UtilityProcess | null = null
  20. let hostReady: Promise<void> | null = null
  21. let nextRequestId = 1
  22. const pendingRequests = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
  23. let inFlight = 0
  24. const STEAM_PID_FILES = [
  25. path.join(os.homedir(), '.steam', 'steam.pid'),
  26. path.join(os.homedir(), '.var', 'app', 'com.valvesoftware.Steam', '.steam', 'steam.pid')
  27. ]
  28. const PROCESS_CHECK_TTL_MS = 5000
  29. let processCheck = { at: 0, alive: false }
  30. // Flatpak Steam writes a pid from its own namespace, hence the /proc scan fallback
  31. function steamProcessAlive(): boolean {
  32. if (Date.now() - processCheck.at < PROCESS_CHECK_TTL_MS) return processCheck.alive
  33. let alive = false
  34. for (const file of STEAM_PID_FILES) {
  35. try {
  36. const pid = Number(fs.readFileSync(file, 'utf8').trim())
  37. if (pid > 0 && fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim() === 'steam') {
  38. alive = true
  39. break
  40. }
  41. } catch {
  42. // no pid file or the process is gone
  43. }
  44. }
  45. if (!alive) {
  46. try {
  47. alive = fs.readdirSync('/proc').some((entry) => {
  48. if (!/^\d+$/.test(entry)) return false
  49. try {
  50. return fs.readFileSync(`/proc/${entry}/comm`, 'utf8').trim() === 'steam'
  51. } catch {
  52. return false
  53. }
  54. })
  55. } catch {
  56. alive = false
  57. }
  58. }
  59. processCheck = { at: Date.now(), alive }
  60. return alive
  61. }
  62. function stopHost(): void {
  63. host?.kill()
  64. host = null
  65. hostReady = null
  66. }
  67. app.on('will-quit', stopHost)
  68. export function bindSteamToWindow(win: BrowserWindow): void {
  69. const update = (): void => {
  70. const active = !win.isDestroyed() && win.isVisible() && !win.isMinimized()
  71. if (active === windowActive) return
  72. windowActive = active
  73. // calls already running get to finish, steamCall() disconnects after the last one
  74. if (!active && host && inFlight === 0) {
  75. console.log('[Steam] Window hidden, disconnecting')
  76. stopHost()
  77. }
  78. }
  79. win.on('show', update)
  80. win.on('hide', update)
  81. win.on('minimize', update)
  82. win.on('restore', update)
  83. win.on('closed', update)
  84. update()
  85. }
  86. export function isSteamAllowed(): boolean {
  87. return windowActive
  88. }
  89. // steamworks.js async calls have no timeout of their own, a hung one would otherwise keep the
  90. // connection open forever
  91. const REQUEST_TIMEOUT_MS = 60 * 1000
  92. function send(proc: UtilityProcess, method: string, args: unknown[]): Promise<unknown> {
  93. const id = nextRequestId++
  94. return new Promise((resolve, reject) => {
  95. const timer = setTimeout(() => {
  96. pendingRequests.delete(id)
  97. reject(new Error(`Steam ${method} timed out`))
  98. }, REQUEST_TIMEOUT_MS)
  99. pendingRequests.set(id, {
  100. resolve: (v) => {
  101. clearTimeout(timer)
  102. resolve(v)
  103. },
  104. reject: (e) => {
  105. clearTimeout(timer)
  106. reject(e)
  107. }
  108. })
  109. proc.postMessage(encodeBigInts({ id, method, args }))
  110. })
  111. }
  112. function startHost(): Promise<void> {
  113. const proc = utilityProcess.fork(path.join(__dirname, 'steam-host.js'), [], {
  114. serviceName: 'we-manager-steam',
  115. stdio: 'inherit'
  116. })
  117. host = proc
  118. proc.on('message', (data) => {
  119. const res = decodeBigInts(data) as SteamHostResponse
  120. const pending = pendingRequests.get(res.id)
  121. if (!pending) return
  122. pendingRequests.delete(res.id)
  123. if (res.error !== undefined) pending.reject(new Error(res.error))
  124. else pending.resolve(res.result)
  125. })
  126. proc.on('exit', (code) => {
  127. if (host === proc) {
  128. host = null
  129. hostReady = null
  130. }
  131. if (pendingRequests.size > 0) {
  132. const err = new Error(`Steam host exited (code ${code})`)
  133. for (const pending of pendingRequests.values()) pending.reject(err)
  134. pendingRequests.clear()
  135. }
  136. })
  137. const appId = getSteamIdentity() === 'standalone' ? STANDALONE_APP_ID : WE_APP_ID
  138. return send(proc, 'init', [appId]).then(
  139. () => {
  140. hasWarnedThisOutage = false
  141. console.log(`[Steam] Connected (app id ${appId})`)
  142. },
  143. (err) => {
  144. if (host === proc) stopHost()
  145. lastInitFailure = Date.now()
  146. if (!hasWarnedThisOutage) {
  147. hasWarnedThisOutage = true
  148. console.warn('[Steam] Init failed (Steam may not be running):', err?.message ?? err)
  149. }
  150. throw err
  151. }
  152. )
  153. }
  154. async function steamCall<T>(method: string, ...args: unknown[]): Promise<T> {
  155. if (!windowActive) throw new Error('Steam is only used while the window is open')
  156. if (!isSteamRunning()) throw new Error('Steam not running')
  157. inFlight++
  158. try {
  159. if (!hostReady) hostReady = startHost()
  160. await hostReady
  161. return (await send(host!, method, args)) as T
  162. } finally {
  163. inFlight--
  164. if (inFlight === 0 && host && !windowActive) {
  165. console.log('[Steam] Window hidden, disconnecting')
  166. stopHost()
  167. }
  168. }
  169. }
  170. export function isSteamRunning(): boolean {
  171. if (!windowActive) return false
  172. if (Date.now() - lastInitFailure < RETRY_COOLDOWN_MS) return false
  173. return steamProcessAlive()
  174. }
  175. export async function getLocalAccountId(): Promise<number> {
  176. const id = await steamCall<{ accountId: number }>('localplayer.getSteamId')
  177. return id.accountId
  178. }
  179. export function getSubscribedIds(): Promise<bigint[]> {
  180. return steamCall<bigint[]>('workshop.getSubscribedItems')
  181. }
  182. export function workshopCall<T>(method: string, ...args: unknown[]): Promise<T> {
  183. return steamCall<T>(`workshop.${method}`, ...args)
  184. }
  185. export async function subscribeToItem(itemId: bigint): Promise<void> {
  186. await steamCall('workshop.subscribe', itemId)
  187. }
  188. // Steam's subscribe doesn't reliably auto-download (seen with items subscribed via the Workshop
  189. // website rather than the Steam client) - items can sit at bare "Subscribed" forever. This is the
  190. // reliable kick; a no-op if already installed or downloading.
  191. export async function downloadItem(itemId: bigint, highPriority = true): Promise<boolean> {
  192. try {
  193. return await steamCall<boolean>('workshop.download', itemId, highPriority)
  194. } catch {
  195. return false
  196. }
  197. }
  198. export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
  199. await steamCall('workshop.unsubscribe', itemId)
  200. }
  201. function isSteamOnline(): Promise<boolean> {
  202. return steamCall<boolean>('isOnline')
  203. }
  204. function sleep(ms: number): Promise<void> {
  205. return new Promise((resolve) => setTimeout(resolve, ms))
  206. }
  207. // steamworks.js async calls have no timeout and can hang forever if Steam stalls
  208. function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  209. return new Promise((resolve, reject) => {
  210. const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
  211. promise.then(
  212. (value) => {
  213. clearTimeout(timer)
  214. resolve(value)
  215. },
  216. (err) => {
  217. clearTimeout(timer)
  218. reject(err)
  219. }
  220. )
  221. })
  222. }
  223. // steamworks.js has no vote bindings; this app's fork adds them (patches/steamworks-vote/), feature-detected so unpatched builds still work
  224. let hasPatchedVoteApi: boolean | null = null
  225. async function patchedVoteApiAvailable(): Promise<boolean> {
  226. if (hasPatchedVoteApi === null) {
  227. const [vote, check] = await Promise.all([
  228. steamCall<boolean>('hasMethod', 'workshop.voteItem'),
  229. steamCall<boolean>('hasMethod', 'workshop.getUserVote')
  230. ])
  231. hasPatchedVoteApi = vote && check
  232. }
  233. return hasPatchedVoteApi
  234. }
  235. export async function checkUserVote(
  236. itemId: bigint
  237. ): Promise<{ votedUp: boolean; votedDown: boolean } | null> {
  238. if (!(await patchedVoteApiAvailable())) return null
  239. const result = await steamCall<{ votedUp: boolean; votedDown: boolean }>('workshop.getUserVote', itemId)
  240. return { votedUp: result.votedUp, votedDown: result.votedDown }
  241. }
  242. export function voteOnItem(itemId: bigint, voteUp: boolean): Promise<bigint> {
  243. return steamCall<bigint>('voteRaw', itemId, voteUp)
  244. }
  245. export async function openWorkshopItemOverlay(itemId: bigint): Promise<void> {
  246. await steamCall('overlay.activateToWebPage', `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`)
  247. }
  248. const USER_ITEM_TYPE = 13 // UGCType.All, since WE items span multiple subtypes
  249. const CREATION_ORDER_DESC = 1
  250. const VOTED_UP = 2
  251. const VOTED_DOWN = 3
  252. const PAGE_FETCH_TIMEOUT_MS = 8000
  253. const VOTE_TIMEOUT_MS = 15000
  254. interface UserItemsPage {
  255. items: Array<{ publishedFileId: bigint } | null | undefined>
  256. returnedResults: number
  257. totalResults: number
  258. }
  259. async function getUserItemsPage(
  260. accountId: number,
  261. listType: number,
  262. page: number,
  263. retries = 2
  264. ): Promise<UserItemsPage> {
  265. for (let attempt = 0; ; attempt++) {
  266. try {
  267. return await withTimeout(
  268. workshopCall<UserItemsPage>(
  269. 'getUserItems', page, accountId, listType, USER_ITEM_TYPE, CREATION_ORDER_DESC, { consumer: WE_APP_ID }
  270. ),
  271. PAGE_FETCH_TIMEOUT_MS,
  272. `getUserItems page ${page}`
  273. )
  274. } catch (err) {
  275. if (attempt >= retries) throw err
  276. await sleep(300 * (attempt + 1))
  277. }
  278. }
  279. }
  280. interface VoteCacheStore {
  281. accountId: number | null
  282. up: string[]
  283. missing: string[]
  284. lastFullSync: number
  285. pending: Record<string, { up: boolean; at: number }>
  286. }
  287. const voteStore = new Store<VoteCacheStore>({
  288. name: 'vote-cache',
  289. defaults: { accountId: null, up: [], missing: [], lastFullSync: 0, pending: {} }
  290. })
  291. let votedUpCache = new Set<string>(voteStore.get('up'))
  292. // an id must be missing on two consecutive full crawls before it is dropped, to absorb pagination drift
  293. let missingLastCycle = new Set<string>(voteStore.get('missing'))
  294. // Steam's voted-up list lags behind the vote itself, so local votes stay authoritative until the crawl agrees or this window runs out
  295. const PENDING_VOTE_TTL_MS = 15 * 60 * 1000
  296. const pendingVotes = new Map<string, { up: boolean; at: number }>(
  297. Object.entries(voteStore.get('pending')).filter(([, v]) => Date.now() - v.at <= PENDING_VOTE_TTL_MS)
  298. )
  299. function applyPendingVotes(fresh: Set<string>): Set<string> {
  300. const now = Date.now()
  301. const merged = new Set(fresh)
  302. for (const [id, vote] of pendingVotes) {
  303. if (fresh.has(id) === vote.up || now - vote.at > PENDING_VOTE_TTL_MS) {
  304. pendingVotes.delete(id)
  305. continue
  306. }
  307. if (vote.up) merged.add(id)
  308. else merged.delete(id)
  309. }
  310. return merged
  311. }
  312. const PERSIST_DELAY_MS = 3000
  313. let persistTimer: ReturnType<typeof setTimeout> | null = null
  314. let persistDirty = false
  315. function persistVoteCache(): void {
  316. persistDirty = true
  317. if (persistTimer) return
  318. persistTimer = setTimeout(flushVoteCache, PERSIST_DELAY_MS)
  319. }
  320. function flushVoteCache(): void {
  321. if (persistTimer) {
  322. clearTimeout(persistTimer)
  323. persistTimer = null
  324. }
  325. if (!persistDirty) return
  326. persistDirty = false
  327. voteStore.set('up', [...votedUpCache])
  328. voteStore.set('missing', [...missingLastCycle])
  329. voteStore.set('pending', Object.fromEntries(pendingVotes))
  330. }
  331. app.on('will-quit', flushVoteCache)
  332. const VOTE_FETCH_CONCURRENCY = 3
  333. const PAGE_PACING_MS = 150
  334. // list sort orders use the item's publish date, not the vote date, so the only way to get the full liked list is to crawl all of it
  335. async function fetchVotedItems(
  336. accountId: number,
  337. listType: number,
  338. onPage: (ids: string[]) => void
  339. ): Promise<Set<string>> {
  340. const ids = new Set<string>()
  341. const take = (items: Array<{ publishedFileId: bigint } | null | undefined>): void => {
  342. const pageIds: string[] = []
  343. for (const item of items) if (item) pageIds.push(item.publishedFileId.toString())
  344. for (const id of pageIds) ids.add(id)
  345. onPage(pageIds)
  346. }
  347. const first = await getUserItemsPage(accountId, listType, 1)
  348. take(first.items)
  349. const pageSize = first.returnedResults
  350. if (pageSize <= 0 || ids.size >= first.totalResults) return ids
  351. const totalPages = Math.ceil(first.totalResults / pageSize)
  352. const remainingPages = Array.from({ length: Math.max(0, totalPages - 1) }, (_, i) => i + 2)
  353. let cursor = 0
  354. async function worker(): Promise<void> {
  355. while (cursor < remainingPages.length) {
  356. const page = remainingPages[cursor++]
  357. const r = await getUserItemsPage(accountId, listType, page)
  358. take(r.items)
  359. await sleep(PAGE_PACING_MS)
  360. }
  361. }
  362. await Promise.all(
  363. Array.from({ length: Math.min(VOTE_FETCH_CONCURRENCY, remainingPages.length) }, worker)
  364. )
  365. return ids
  366. }
  367. const FULL_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000
  368. const SYNC_CHECK_INTERVAL_MS = 10 * 60 * 1000
  369. const SYNC_STARTUP_DELAY_MS = 20 * 1000
  370. const PROGRESS_NOTIFY_INTERVAL_MS = 15 * 1000
  371. let votedIdsChangeListener: ((ids: string[]) => void) | null = null
  372. let syncInFlight = false
  373. let lastSyncFailure = 0
  374. function notifyVotedIds(): void {
  375. votedIdsChangeListener?.([...votedUpCache])
  376. }
  377. async function runFullSync(): Promise<void> {
  378. const accountId = await getLocalAccountId()
  379. if (voteStore.get('accountId') !== null && voteStore.get('accountId') !== accountId) {
  380. votedUpCache = new Set()
  381. missingLastCycle = new Set()
  382. pendingVotes.clear()
  383. notifyVotedIds()
  384. }
  385. voteStore.set('accountId', accountId)
  386. let lastNotify = Date.now()
  387. const seen = await fetchVotedItems(accountId, VOTED_UP, (pageIds) => {
  388. let added = false
  389. for (const id of pageIds) {
  390. if (!votedUpCache.has(id) && pendingVotes.get(id)?.up !== false) {
  391. votedUpCache.add(id)
  392. added = true
  393. }
  394. }
  395. if (!added) return
  396. persistVoteCache()
  397. if (Date.now() - lastNotify >= PROGRESS_NOTIFY_INTERVAL_MS) {
  398. lastNotify = Date.now()
  399. notifyVotedIds()
  400. }
  401. })
  402. const fresh = applyPendingVotes(seen)
  403. const merged = new Set(fresh)
  404. const missingThisCycle = new Set<string>()
  405. for (const id of votedUpCache) {
  406. if (fresh.has(id)) continue
  407. if (missingLastCycle.has(id)) continue
  408. merged.add(id)
  409. missingThisCycle.add(id)
  410. }
  411. const changed = !sameIds(votedUpCache, merged)
  412. missingLastCycle = missingThisCycle
  413. votedUpCache = merged
  414. voteStore.set('lastFullSync', Date.now())
  415. persistVoteCache()
  416. if (changed) notifyVotedIds()
  417. console.log(`[Steam] Liked list synced: ${votedUpCache.size} items`)
  418. }
  419. function maybeRunFullSync(): void {
  420. if (syncInFlight || !isSteamRunning()) return
  421. const now = Date.now()
  422. if (now - voteStore.get('lastFullSync') < FULL_SYNC_INTERVAL_MS) return
  423. if (now - lastSyncFailure < SYNC_CHECK_INTERVAL_MS) return
  424. syncInFlight = true
  425. runFullSync()
  426. .catch((err) => {
  427. lastSyncFailure = Date.now()
  428. console.warn('[Steam] Liked list sync failed, will retry later:', err?.message ?? err)
  429. })
  430. .finally(() => {
  431. syncInFlight = false
  432. })
  433. }
  434. function recordVote(idStr: string, voteUp: boolean): void {
  435. pendingVotes.set(idStr, { up: voteUp, at: Date.now() })
  436. if (voteUp) votedUpCache.add(idStr)
  437. else votedUpCache.delete(idStr)
  438. missingLastCycle.delete(idStr)
  439. persistVoteCache()
  440. }
  441. const VOTE_ATTEMPTS = 4
  442. const VOTE_RETRY_BASE_MS = 1500
  443. // Steam rejects or drops SetUserItemVote calls that overlap, so votes go out one at a time
  444. const VOTE_GAP_MS = 400
  445. let voteQueue: Promise<unknown> = Promise.resolve()
  446. // kept for this session only, a failure usually means Steam was busy or offline at the time
  447. const failedVotes = new Map<string, { up: boolean; message: string }>()
  448. let failedVotesChangeListener: ((failed: Record<string, string>) => void) | null = null
  449. export function getFailedVotes(): Record<string, string> {
  450. const out: Record<string, string> = {}
  451. for (const [id, failure] of failedVotes) {
  452. // liked elsewhere since, e.g. on the website or picked up by the background sync
  453. if (failure.up && votedUpCache.has(id)) failedVotes.delete(id)
  454. else out[id] = failure.message
  455. }
  456. return out
  457. }
  458. export function onFailedVotesChange(cb: (failed: Record<string, string>) => void): void {
  459. failedVotesChangeListener = cb
  460. }
  461. function setVoteFailure(idStr: string, failure: { up: boolean; message: string } | null): void {
  462. if (failure === null) {
  463. if (!failedVotes.delete(idStr)) return
  464. } else {
  465. failedVotes.set(idStr, failure)
  466. }
  467. failedVotesChangeListener?.(getFailedVotes())
  468. }
  469. export function voteOnItemAndConfirm(itemId: bigint, voteUp: boolean): Promise<boolean> {
  470. const run = voteQueue.then(() => voteWithRetry(itemId, voteUp))
  471. voteQueue = run.catch(() => {}).then(() => sleep(VOTE_GAP_MS))
  472. return run
  473. }
  474. async function voteWithRetry(itemId: bigint, voteUp: boolean): Promise<boolean> {
  475. let lastErr: unknown
  476. for (let attempt = 0; attempt < VOTE_ATTEMPTS; attempt++) {
  477. if (attempt > 0) await sleep(VOTE_RETRY_BASE_MS * 2 ** (attempt - 1))
  478. if (!isSteamRunning()) throw new Error('Steam is not running - start Steam to like wallpapers')
  479. if (!(await isSteamOnline())) throw new Error('Steam is offline - check your internet connection and try again')
  480. try {
  481. const ok = await voteOnce(itemId, voteUp)
  482. setVoteFailure(itemId.toString(), null)
  483. return ok
  484. } catch (err) {
  485. lastErr = err
  486. console.warn(`[Steam] Vote on ${itemId} failed (attempt ${attempt + 1}/${VOTE_ATTEMPTS}):`, (err as Error)?.message ?? err)
  487. }
  488. }
  489. const msg = (lastErr as Error)?.message ?? String(lastErr)
  490. setVoteFailure(itemId.toString(), {
  491. up: voteUp,
  492. message: `${voteUp ? 'Like' : 'Dislike'} failed after ${VOTE_ATTEMPTS} attempts: ${msg}`
  493. })
  494. throw new Error(`Could not reach Steam to ${voteUp ? 'like' : 'dislike'} this wallpaper: ${msg}`)
  495. }
  496. // Without the patched steamworks.js, success is reported once SetUserItemVote is enqueued: its result can't be read back,
  497. // because steamworks.js's manual dispatch loop consumes every call result on the shared Steam pipe first.
  498. async function voteOnce(itemId: bigint, voteUp: boolean): Promise<boolean> {
  499. const idStr = itemId.toString()
  500. if (await patchedVoteApiAvailable()) {
  501. await withTimeout(steamCall('workshop.voteItem', itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote')
  502. } else {
  503. await voteOnItem(itemId, voteUp)
  504. }
  505. recordVote(idStr, voteUp)
  506. return true
  507. }
  508. function sameIds(a: Set<string>, b: Set<string>): boolean {
  509. if (a.size !== b.size) return false
  510. for (const id of a) if (!b.has(id)) return false
  511. return true
  512. }
  513. export async function getVotedUpItemIds(): Promise<string[]> {
  514. return [...votedUpCache]
  515. }
  516. // a vote this fresh may not have reached Steam's per-item lookup yet
  517. const VOTE_SETTLE_MS = 10_000
  518. export async function refreshItemVote(itemId: bigint): Promise<boolean | null> {
  519. if (!isSteamRunning() || !(await isSteamOnline().catch(() => false))) return null
  520. const idStr = itemId.toString()
  521. const result = await withTimeout(checkUserVote(itemId), PAGE_FETCH_TIMEOUT_MS, 'getUserVote').catch((err) => {
  522. console.warn('[Steam] Per-item vote check failed:', err?.message ?? err)
  523. return null
  524. })
  525. if (!result) return null
  526. const pending = pendingVotes.get(idStr)
  527. if (pending && Date.now() - pending.at < VOTE_SETTLE_MS) return pending.up
  528. pendingVotes.delete(idStr)
  529. missingLastCycle.delete(idStr)
  530. if (votedUpCache.has(idStr) !== result.votedUp) {
  531. if (result.votedUp) votedUpCache.add(idStr)
  532. else votedUpCache.delete(idStr)
  533. persistVoteCache()
  534. notifyVotedIds()
  535. }
  536. return result.votedUp
  537. }
  538. let voteSyncTimer: ReturnType<typeof setInterval> | null = null
  539. export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
  540. votedIdsChangeListener = onChange
  541. if (voteSyncTimer) return
  542. setTimeout(maybeRunFullSync, SYNC_STARTUP_DELAY_MS)
  543. voteSyncTimer = setInterval(maybeRunFullSync, SYNC_CHECK_INTERVAL_MS)
  544. }
  545. export async function getSubscribedItems(): Promise<string[]> {
  546. return (await getSubscribedIds()).map((id) => id.toString())
  547. }
  548. export async function getDownloadInfo(itemId: bigint): Promise<DownloadProgressEvent | null> {
  549. try {
  550. const info = await workshopCall<{ current: bigint; total: bigint } | null>('downloadInfo', itemId)
  551. if (!info) return null
  552. const bytesTotal = Number(info.total)
  553. const bytesDownloaded = Number(info.current)
  554. return {
  555. itemId: itemId.toString(),
  556. bytesDownloaded,
  557. bytesTotal,
  558. percentage: bytesTotal > 0 ? Math.round((bytesDownloaded / bytesTotal) * 100) : 0,
  559. status: 'downloading'
  560. }
  561. } catch {
  562. return null
  563. }
  564. }
  565. export async function getInstallInfo(itemId: bigint): Promise<{ folder: string; sizeOnDisk: number } | null> {
  566. try {
  567. const info = await workshopCall<{ folder: string; sizeOnDisk: bigint } | null>('installInfo', itemId)
  568. if (!info) return null
  569. return { folder: info.folder, sizeOnDisk: Number(info.sizeOnDisk) }
  570. } catch {
  571. return null
  572. }
  573. }
  574. // steamworks.js only exposes persona info for the local player/friends, so this
  575. // pulls it from the public community profile XML instead. Cached for the process lifetime.
  576. const authorInfoCache = new Map<string, WorkshopAuthorInfo | null>()
  577. export async function getAuthorInfo(steamId: string): Promise<WorkshopAuthorInfo | null> {
  578. if (authorInfoCache.has(steamId)) return authorInfoCache.get(steamId) ?? null
  579. try {
  580. const res = await fetch(`https://steamcommunity.com/profiles/${steamId}?xml=1`)
  581. const xml = await res.text()
  582. const name = xml.match(/<steamID><!\[CDATA\[([\s\S]*?)\]\]><\/steamID>/)?.[1]
  583. const avatarUrl = xml.match(/<avatarFull><!\[CDATA\[([\s\S]*?)\]\]><\/avatarFull>/)?.[1]
  584. const info = name && avatarUrl ? { steamId, name, avatarUrl } : null
  585. authorInfoCache.set(steamId, info)
  586. return info
  587. } catch {
  588. authorInfoCache.set(steamId, null)
  589. return null
  590. }
  591. }
  592. export async function getItemState(itemId: bigint): Promise<number> {
  593. try {
  594. return await workshopCall<number>('state', itemId)
  595. } catch {
  596. return 0
  597. }
  598. }
  599. // EItemState flags from the Steamworks SDK (isteamugc.h)
  600. const ITEM_STATE_INSTALLED = 4
  601. const ITEM_STATE_NEEDS_UPDATE = 8
  602. const ITEM_STATE_DOWNLOADING = 16
  603. const ITEM_STATE_DOWNLOAD_PENDING = 32
  604. // True for a subscribed item Steam has never actually started fetching - not installed, not
  605. // mid-transfer, not even flagged as needing an update. See downloadItem() above.
  606. function isStuckNeverDownloaded(state: number): boolean {
  607. const installed = (state & ITEM_STATE_INSTALLED) !== 0
  608. const inProgress = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING | ITEM_STATE_NEEDS_UPDATE)) !== 0
  609. return !installed && !inProgress
  610. }
  611. export async function isItemStuckNeverDownloaded(itemId: bigint): Promise<boolean> {
  612. return isStuckNeverDownloaded(await getItemState(itemId))
  613. }
  614. export async function isItemDownloading(itemId: bigint): Promise<boolean> {
  615. const state = await getItemState(itemId)
  616. return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
  617. }
  618. // NeedsUpdate stays set while Steam hasn't caught the local copy up to the server version. If
  619. // it's set but nothing is actively downloading/pending, Steam gave up on the transfer (e.g. it
  620. // errored out or the disk filled up). A never-downloaded item reports neither bit - treated as
  621. // "downloading" since the caller (scanLibrary) just kicked one off for it.
  622. export async function getItemDownloadStatus(itemId: bigint): Promise<{ downloading: boolean; failed: boolean }> {
  623. const state = await getItemState(itemId)
  624. const downloading =
  625. (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0 || isStuckNeverDownloaded(state)
  626. const failed = !downloading && (state & ITEM_STATE_NEEDS_UPDATE) !== 0
  627. return { downloading, failed }
  628. }