steam.service.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import * as path from 'path'
  2. import Store from 'electron-store'
  3. import { app } from 'electron'
  4. import { WE_APP_ID, STANDALONE_APP_ID } from '@shared/constants'
  5. import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
  6. import { getSteamIdentity } from './config.service'
  7. let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
  8. // Steam not running is a normal, common state (this app works as a local library manager
  9. // without it). Every isSteamRunning()/getClient() call used to retry the native init and log a
  10. // warning, so anything that polls (react-query refetches, library scans, ...) turned "Steam is
  11. // closed" into a continuous stream of console warnings. Only actually retry - and only warn -
  12. // once per cooldown window, and only log the first failure of a run.
  13. const RETRY_COOLDOWN_MS = 15000
  14. let lastInitAttempt = 0
  15. let hasWarnedThisOutage = false
  16. export function initSteam(): boolean {
  17. lastInitAttempt = Date.now()
  18. try {
  19. // steamworks.js is a native module and must be required at runtime
  20. // eslint-disable-next-line @typescript-eslint/no-require-imports
  21. const steamworks: typeof import('steamworks.js') = require('steamworks.js')
  22. const appId = getSteamIdentity() === 'standalone' ? STANDALONE_APP_ID : WE_APP_ID
  23. client = steamworks.init(appId)
  24. hasWarnedThisOutage = false
  25. console.log(`[Steam] Initialized (app id ${appId})`)
  26. return true
  27. } catch (err) {
  28. client = null
  29. if (!hasWarnedThisOutage) {
  30. hasWarnedThisOutage = true
  31. console.warn('[Steam] Init failed (Steam may not be running):', err)
  32. }
  33. return false
  34. }
  35. }
  36. function ensureClient(): void {
  37. if (client) return
  38. if (Date.now() - lastInitAttempt < RETRY_COOLDOWN_MS) return
  39. initSteam()
  40. }
  41. export function isSteamRunning(): boolean {
  42. ensureClient()
  43. return client !== null
  44. }
  45. export function getClient() {
  46. ensureClient()
  47. if (!client) throw new Error('Steam not initialized')
  48. return client
  49. }
  50. export async function subscribeToItem(itemId: bigint): Promise<void> {
  51. await getClient().workshop.subscribe(itemId)
  52. }
  53. // Steam's subscribe doesn't reliably auto-download (seen with items subscribed via the Workshop
  54. // website rather than the Steam client) - items can sit at bare "Subscribed" forever. This is the
  55. // reliable kick; a no-op if already installed or downloading.
  56. export function downloadItem(itemId: bigint, highPriority = true): boolean {
  57. try {
  58. return getClient().workshop.download(itemId, highPriority)
  59. } catch {
  60. return false
  61. }
  62. }
  63. export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
  64. await getClient().workshop.unsubscribe(itemId)
  65. }
  66. // ISteamUGC::SetUserItemVote is not bound in steamworks.js, so we call the
  67. // flat C API directly via koffi FFI. libsteam_api.so is already loaded by
  68. // steamworks.js, so dlopen just returns the existing handle. Set up lazily
  69. // after SteamAPI_Init so a load failure can't take down the whole app.
  70. // eslint-disable-next-line @typescript-eslint/no-require-imports
  71. type Koffi = typeof import('koffi')
  72. let steamLibHandle: ReturnType<Koffi['load']> | null = null
  73. function getSteamLib(): ReturnType<Koffi['load']> {
  74. if (!steamLibHandle) {
  75. // eslint-disable-next-line @typescript-eslint/no-require-imports
  76. const koffi: Koffi = require('koffi')
  77. const libPath = path.join(
  78. path.dirname(require.resolve('steamworks.js')),
  79. 'dist', 'linux64', 'libsteam_api.so'
  80. ).replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked')
  81. steamLibHandle = koffi.load(libPath)
  82. }
  83. return steamLibHandle
  84. }
  85. interface UgcFfi {
  86. setUserItemVote: (ugc: unknown, itemId: bigint, voteUp: boolean) => bigint
  87. ugcPtr: unknown
  88. }
  89. let ugcFfi: UgcFfi | null = null
  90. function getUgcFfi(): UgcFfi {
  91. if (!ugcFfi) {
  92. // eslint-disable-next-line @typescript-eslint/no-require-imports
  93. const koffi: Koffi = require('koffi')
  94. const steamLib = getSteamLib()
  95. const ugcPtrType = koffi.pointer(koffi.opaque('ISteamUGC'))
  96. const getSteamUGC = steamLib.func('SteamAPI_SteamUGC_v020', ugcPtrType, [])
  97. ugcFfi = {
  98. setUserItemVote: steamLib.func(
  99. 'SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [ugcPtrType, 'uint64', 'bool']
  100. ),
  101. ugcPtr: getSteamUGC()
  102. }
  103. }
  104. return ugcFfi
  105. }
  106. let isLoggedOnFn: (() => boolean) | null = null
  107. function isSteamOnline(): boolean {
  108. if (!isLoggedOnFn) {
  109. // eslint-disable-next-line @typescript-eslint/no-require-imports
  110. const koffi: Koffi = require('koffi')
  111. const steamLib = getSteamLib()
  112. const userPtrType = koffi.pointer(koffi.opaque('ISteamUser'))
  113. const getSteamUser = steamLib.func('SteamAPI_SteamUser_v023', userPtrType, [])
  114. const bLoggedOn = steamLib.func('SteamAPI_ISteamUser_BLoggedOn', 'bool', [userPtrType])
  115. const userPtr = getSteamUser()
  116. isLoggedOnFn = () => bLoggedOn(userPtr)
  117. }
  118. return isLoggedOnFn()
  119. }
  120. function sleep(ms: number): Promise<void> {
  121. return new Promise((resolve) => setTimeout(resolve, ms))
  122. }
  123. // steamworks.js async calls have no timeout and can hang forever if Steam stalls
  124. function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  125. return new Promise((resolve, reject) => {
  126. const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
  127. promise.then(
  128. (value) => {
  129. clearTimeout(timer)
  130. resolve(value)
  131. },
  132. (err) => {
  133. clearTimeout(timer)
  134. reject(err)
  135. }
  136. )
  137. })
  138. }
  139. // steamworks.js has no vote bindings; this app's fork adds them (patches/steamworks-vote/), feature-detected so unpatched builds still work
  140. interface PatchedVoteWorkshop {
  141. voteItem(itemId: bigint, voteUp: boolean): Promise<void>
  142. getUserVote(itemId: bigint): Promise<{ votedUp: boolean; votedDown: boolean; voteSkipped: boolean }>
  143. }
  144. function getPatchedVoteApi(client: ReturnType<typeof getClient>): PatchedVoteWorkshop | null {
  145. const workshop = client.workshop as unknown as Partial<PatchedVoteWorkshop>
  146. if (typeof workshop.voteItem === 'function' && typeof workshop.getUserVote === 'function') {
  147. return workshop as PatchedVoteWorkshop
  148. }
  149. return null
  150. }
  151. export async function checkUserVote(
  152. itemId: bigint
  153. ): Promise<{ votedUp: boolean; votedDown: boolean } | null> {
  154. const patched = getPatchedVoteApi(getClient())
  155. if (!patched) return null
  156. const result = await patched.getUserVote(itemId)
  157. return { votedUp: result.votedUp, votedDown: result.votedDown }
  158. }
  159. export function voteOnItem(itemId: bigint, voteUp: boolean): bigint {
  160. getClient() // ensure Steam is initialized before touching the flat API
  161. const ffi = getUgcFfi()
  162. const handle = ffi.setUserItemVote(ffi.ugcPtr, itemId, voteUp)
  163. // k_uAPICallInvalid = 0 means the call failed immediately
  164. if (handle === BigInt(0)) throw new Error('SetUserItemVote failed')
  165. return handle
  166. }
  167. export function openWorkshopItemOverlay(itemId: bigint): void {
  168. getClient().overlay.activateToWebPage(
  169. `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
  170. )
  171. }
  172. const USER_ITEM_TYPE = 13 // UGCType.All, since WE items span multiple subtypes
  173. const CREATION_ORDER_DESC = 1
  174. const VOTED_UP = 2
  175. const VOTED_DOWN = 3
  176. const PAGE_FETCH_TIMEOUT_MS = 8000
  177. const VOTE_TIMEOUT_MS = 15000
  178. async function getUserItemsPage(
  179. accountId: number,
  180. listType: number,
  181. page: number,
  182. retries = 2
  183. ): Promise<{ items: Array<{ publishedFileId: bigint } | null | undefined>; returnedResults: number; totalResults: number }> {
  184. const c = getClient()
  185. for (let attempt = 0; ; attempt++) {
  186. try {
  187. return await withTimeout(
  188. c.workshop.getUserItems(
  189. page, accountId, listType, USER_ITEM_TYPE, CREATION_ORDER_DESC, { consumer: WE_APP_ID }
  190. ),
  191. PAGE_FETCH_TIMEOUT_MS,
  192. `getUserItems page ${page}`
  193. )
  194. } catch (err) {
  195. if (attempt >= retries) throw err
  196. await sleep(300 * (attempt + 1))
  197. }
  198. }
  199. }
  200. interface VoteCacheStore {
  201. accountId: number | null
  202. up: string[]
  203. missing: string[]
  204. lastFullSync: number
  205. pending: Record<string, { up: boolean; at: number }>
  206. }
  207. const voteStore = new Store<VoteCacheStore>({
  208. name: 'vote-cache',
  209. defaults: { accountId: null, up: [], missing: [], lastFullSync: 0, pending: {} }
  210. })
  211. let votedUpCache = new Set<string>(voteStore.get('up'))
  212. // an id must be missing on two consecutive full crawls before it is dropped, to absorb pagination drift
  213. let missingLastCycle = new Set<string>(voteStore.get('missing'))
  214. // Steam's voted-up list lags behind the vote itself, so local votes stay authoritative until the crawl agrees or this window runs out
  215. const PENDING_VOTE_TTL_MS = 15 * 60 * 1000
  216. const pendingVotes = new Map<string, { up: boolean; at: number }>(
  217. Object.entries(voteStore.get('pending')).filter(([, v]) => Date.now() - v.at <= PENDING_VOTE_TTL_MS)
  218. )
  219. function applyPendingVotes(fresh: Set<string>): Set<string> {
  220. const now = Date.now()
  221. const merged = new Set(fresh)
  222. for (const [id, vote] of pendingVotes) {
  223. if (fresh.has(id) === vote.up || now - vote.at > PENDING_VOTE_TTL_MS) {
  224. pendingVotes.delete(id)
  225. continue
  226. }
  227. if (vote.up) merged.add(id)
  228. else merged.delete(id)
  229. }
  230. return merged
  231. }
  232. const PERSIST_DELAY_MS = 3000
  233. let persistTimer: ReturnType<typeof setTimeout> | null = null
  234. let persistDirty = false
  235. function persistVoteCache(): void {
  236. persistDirty = true
  237. if (persistTimer) return
  238. persistTimer = setTimeout(flushVoteCache, PERSIST_DELAY_MS)
  239. }
  240. function flushVoteCache(): void {
  241. if (persistTimer) {
  242. clearTimeout(persistTimer)
  243. persistTimer = null
  244. }
  245. if (!persistDirty) return
  246. persistDirty = false
  247. voteStore.set('up', [...votedUpCache])
  248. voteStore.set('missing', [...missingLastCycle])
  249. voteStore.set('pending', Object.fromEntries(pendingVotes))
  250. }
  251. app.on('will-quit', flushVoteCache)
  252. const VOTE_FETCH_CONCURRENCY = 3
  253. const PAGE_PACING_MS = 150
  254. // 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
  255. async function fetchVotedItems(
  256. accountId: number,
  257. listType: number,
  258. onPage: (ids: string[]) => void
  259. ): Promise<Set<string>> {
  260. const ids = new Set<string>()
  261. const take = (items: Array<{ publishedFileId: bigint } | null | undefined>): void => {
  262. const pageIds: string[] = []
  263. for (const item of items) if (item) pageIds.push(item.publishedFileId.toString())
  264. for (const id of pageIds) ids.add(id)
  265. onPage(pageIds)
  266. }
  267. const first = await getUserItemsPage(accountId, listType, 1)
  268. take(first.items)
  269. const pageSize = first.returnedResults
  270. if (pageSize <= 0 || ids.size >= first.totalResults) return ids
  271. const totalPages = Math.ceil(first.totalResults / pageSize)
  272. const remainingPages = Array.from({ length: Math.max(0, totalPages - 1) }, (_, i) => i + 2)
  273. let cursor = 0
  274. async function worker(): Promise<void> {
  275. while (cursor < remainingPages.length) {
  276. const page = remainingPages[cursor++]
  277. const r = await getUserItemsPage(accountId, listType, page)
  278. take(r.items)
  279. await sleep(PAGE_PACING_MS)
  280. }
  281. }
  282. await Promise.all(
  283. Array.from({ length: Math.min(VOTE_FETCH_CONCURRENCY, remainingPages.length) }, worker)
  284. )
  285. return ids
  286. }
  287. const FULL_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000
  288. const SYNC_CHECK_INTERVAL_MS = 10 * 60 * 1000
  289. const SYNC_STARTUP_DELAY_MS = 20 * 1000
  290. const PROGRESS_NOTIFY_INTERVAL_MS = 15 * 1000
  291. let votedIdsChangeListener: ((ids: string[]) => void) | null = null
  292. let syncInFlight = false
  293. let lastSyncFailure = 0
  294. function notifyVotedIds(): void {
  295. votedIdsChangeListener?.([...votedUpCache])
  296. }
  297. async function runFullSync(): Promise<void> {
  298. const accountId = getClient().localplayer.getSteamId().accountId
  299. if (voteStore.get('accountId') !== null && voteStore.get('accountId') !== accountId) {
  300. votedUpCache = new Set()
  301. missingLastCycle = new Set()
  302. pendingVotes.clear()
  303. notifyVotedIds()
  304. }
  305. voteStore.set('accountId', accountId)
  306. let lastNotify = Date.now()
  307. const seen = await fetchVotedItems(accountId, VOTED_UP, (pageIds) => {
  308. let added = false
  309. for (const id of pageIds) {
  310. if (!votedUpCache.has(id) && pendingVotes.get(id)?.up !== false) {
  311. votedUpCache.add(id)
  312. added = true
  313. }
  314. }
  315. if (!added) return
  316. persistVoteCache()
  317. if (Date.now() - lastNotify >= PROGRESS_NOTIFY_INTERVAL_MS) {
  318. lastNotify = Date.now()
  319. notifyVotedIds()
  320. }
  321. })
  322. const fresh = applyPendingVotes(seen)
  323. const merged = new Set(fresh)
  324. const missingThisCycle = new Set<string>()
  325. for (const id of votedUpCache) {
  326. if (fresh.has(id)) continue
  327. if (missingLastCycle.has(id)) continue
  328. merged.add(id)
  329. missingThisCycle.add(id)
  330. }
  331. const changed = !sameIds(votedUpCache, merged)
  332. missingLastCycle = missingThisCycle
  333. votedUpCache = merged
  334. voteStore.set('lastFullSync', Date.now())
  335. persistVoteCache()
  336. if (changed) notifyVotedIds()
  337. console.log(`[Steam] Liked list synced: ${votedUpCache.size} items`)
  338. }
  339. function maybeRunFullSync(): void {
  340. if (syncInFlight || !isSteamRunning()) return
  341. const now = Date.now()
  342. if (now - voteStore.get('lastFullSync') < FULL_SYNC_INTERVAL_MS) return
  343. if (now - lastSyncFailure < SYNC_CHECK_INTERVAL_MS) return
  344. syncInFlight = true
  345. runFullSync()
  346. .catch((err) => {
  347. lastSyncFailure = Date.now()
  348. console.warn('[Steam] Liked list sync failed, will retry later:', err?.message ?? err)
  349. })
  350. .finally(() => {
  351. syncInFlight = false
  352. })
  353. }
  354. function recordVote(idStr: string, voteUp: boolean): void {
  355. pendingVotes.set(idStr, { up: voteUp, at: Date.now() })
  356. if (voteUp) votedUpCache.add(idStr)
  357. else votedUpCache.delete(idStr)
  358. missingLastCycle.delete(idStr)
  359. persistVoteCache()
  360. }
  361. // Without the patched steamworks.js, success is reported once SetUserItemVote is enqueued: its result can't be read back,
  362. // because steamworks.js's manual dispatch loop consumes every call result on the shared Steam pipe first.
  363. export async function voteOnItemAndConfirm(itemId: bigint, voteUp: boolean): Promise<boolean> {
  364. if (!isSteamRunning()) throw new Error('Steam is not running - start Steam to like wallpapers')
  365. const client = getClient()
  366. if (!isSteamOnline()) throw new Error('Steam is offline - check your internet connection and try again')
  367. const idStr = itemId.toString()
  368. const patched = getPatchedVoteApi(client)
  369. if (patched) {
  370. await withTimeout(patched.voteItem(itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote').catch((err) => {
  371. throw new Error(`Could not reach Steam to ${voteUp ? 'like' : 'dislike'} this wallpaper: ${err?.message ?? err}`)
  372. })
  373. recordVote(idStr, voteUp)
  374. return true
  375. }
  376. voteOnItem(itemId, voteUp)
  377. recordVote(idStr, voteUp)
  378. return true
  379. }
  380. function sameIds(a: Set<string>, b: Set<string>): boolean {
  381. if (a.size !== b.size) return false
  382. for (const id of a) if (!b.has(id)) return false
  383. return true
  384. }
  385. export async function getVotedUpItemIds(): Promise<string[]> {
  386. return [...votedUpCache]
  387. }
  388. let voteSyncTimer: ReturnType<typeof setInterval> | null = null
  389. export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
  390. votedIdsChangeListener = onChange
  391. if (voteSyncTimer) return
  392. setTimeout(maybeRunFullSync, SYNC_STARTUP_DELAY_MS)
  393. voteSyncTimer = setInterval(maybeRunFullSync, SYNC_CHECK_INTERVAL_MS)
  394. }
  395. export function getSubscribedItems(): string[] {
  396. return getClient()
  397. .workshop.getSubscribedItems()
  398. .map((id) => id.toString())
  399. }
  400. export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
  401. try {
  402. const info = getClient().workshop.downloadInfo(itemId)
  403. if (!info) return null
  404. const bytesTotal = Number(info.total)
  405. const bytesDownloaded = Number(info.current)
  406. return {
  407. itemId: itemId.toString(),
  408. bytesDownloaded,
  409. bytesTotal,
  410. percentage: bytesTotal > 0 ? Math.round((bytesDownloaded / bytesTotal) * 100) : 0,
  411. status: 'downloading'
  412. }
  413. } catch {
  414. return null
  415. }
  416. }
  417. export function getInstallInfo(itemId: bigint): { folder: string; sizeOnDisk: number } | null {
  418. try {
  419. const info = getClient().workshop.installInfo(itemId)
  420. if (!info) return null
  421. return { folder: info.folder, sizeOnDisk: Number(info.sizeOnDisk) }
  422. } catch {
  423. return null
  424. }
  425. }
  426. // steamworks.js only exposes persona info for the local player/friends, so this
  427. // pulls it from the public community profile XML instead. Cached for the process lifetime.
  428. const authorInfoCache = new Map<string, WorkshopAuthorInfo | null>()
  429. export async function getAuthorInfo(steamId: string): Promise<WorkshopAuthorInfo | null> {
  430. if (authorInfoCache.has(steamId)) return authorInfoCache.get(steamId) ?? null
  431. try {
  432. const res = await fetch(`https://steamcommunity.com/profiles/${steamId}?xml=1`)
  433. const xml = await res.text()
  434. const name = xml.match(/<steamID><!\[CDATA\[([\s\S]*?)\]\]><\/steamID>/)?.[1]
  435. const avatarUrl = xml.match(/<avatarFull><!\[CDATA\[([\s\S]*?)\]\]><\/avatarFull>/)?.[1]
  436. const info = name && avatarUrl ? { steamId, name, avatarUrl } : null
  437. authorInfoCache.set(steamId, info)
  438. return info
  439. } catch {
  440. authorInfoCache.set(steamId, null)
  441. return null
  442. }
  443. }
  444. export function getItemState(itemId: bigint): number {
  445. try {
  446. return getClient().workshop.state(itemId) as number
  447. } catch {
  448. return 0
  449. }
  450. }
  451. // EItemState flags from the Steamworks SDK (isteamugc.h)
  452. const ITEM_STATE_INSTALLED = 4
  453. const ITEM_STATE_NEEDS_UPDATE = 8
  454. const ITEM_STATE_DOWNLOADING = 16
  455. const ITEM_STATE_DOWNLOAD_PENDING = 32
  456. // True for a subscribed item Steam has never actually started fetching - not installed, not
  457. // mid-transfer, not even flagged as needing an update. See downloadItem() above.
  458. export function isItemStuckNeverDownloaded(itemId: bigint): boolean {
  459. const state = getItemState(itemId)
  460. const installed = (state & ITEM_STATE_INSTALLED) !== 0
  461. const inProgress = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING | ITEM_STATE_NEEDS_UPDATE)) !== 0
  462. return !installed && !inProgress
  463. }
  464. export function isItemDownloading(itemId: bigint): boolean {
  465. const state = getItemState(itemId)
  466. return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
  467. }
  468. // NeedsUpdate stays set while Steam hasn't caught the local copy up to the server version. If
  469. // it's set but nothing is actively downloading/pending, Steam gave up on the transfer (e.g. it
  470. // errored out or the disk filled up). A never-downloaded item reports neither bit - treated as
  471. // "downloading" since the caller (scanLibrary) just kicked one off for it.
  472. export function getItemDownloadStatus(itemId: bigint): { downloading: boolean; failed: boolean } {
  473. const state = getItemState(itemId)
  474. const downloading =
  475. (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0 || isItemStuckNeverDownloaded(itemId)
  476. const failed = !downloading && (state & ITEM_STATE_NEEDS_UPDATE) !== 0
  477. return { downloading, failed }
  478. }