steam.service.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import * as path from 'path'
  2. import { WE_APP_ID, STANDALONE_APP_ID } from '@shared/constants'
  3. import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
  4. import { getSteamIdentity } from './config.service'
  5. let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
  6. // Steam not running is a normal, common state (this app works as a local library manager
  7. // without it). Every isSteamRunning()/getClient() call used to retry the native init and log a
  8. // warning, so anything that polls (react-query refetches, library scans, ...) turned "Steam is
  9. // closed" into a continuous stream of console warnings. Only actually retry - and only warn -
  10. // once per cooldown window, and only log the first failure of a run.
  11. const RETRY_COOLDOWN_MS = 15000
  12. let lastInitAttempt = 0
  13. let hasWarnedThisOutage = false
  14. export function initSteam(): boolean {
  15. lastInitAttempt = Date.now()
  16. try {
  17. // steamworks.js is a native module and must be required at runtime
  18. // eslint-disable-next-line @typescript-eslint/no-require-imports
  19. const steamworks: typeof import('steamworks.js') = require('steamworks.js')
  20. const appId = getSteamIdentity() === 'standalone' ? STANDALONE_APP_ID : WE_APP_ID
  21. client = steamworks.init(appId)
  22. hasWarnedThisOutage = false
  23. console.log(`[Steam] Initialized (app id ${appId})`)
  24. return true
  25. } catch (err) {
  26. client = null
  27. if (!hasWarnedThisOutage) {
  28. hasWarnedThisOutage = true
  29. console.warn('[Steam] Init failed (Steam may not be running):', err)
  30. }
  31. return false
  32. }
  33. }
  34. function ensureClient(): void {
  35. if (client) return
  36. if (Date.now() - lastInitAttempt < RETRY_COOLDOWN_MS) return
  37. initSteam()
  38. }
  39. export function isSteamRunning(): boolean {
  40. ensureClient()
  41. return client !== null
  42. }
  43. export function getClient() {
  44. ensureClient()
  45. if (!client) throw new Error('Steam not initialized')
  46. return client
  47. }
  48. export async function subscribeToItem(itemId: bigint): Promise<void> {
  49. await getClient().workshop.subscribe(itemId)
  50. }
  51. // Steam's subscribe doesn't reliably auto-download (seen with items subscribed via the Workshop
  52. // website rather than the Steam client) - items can sit at bare "Subscribed" forever. This is the
  53. // reliable kick; a no-op if already installed or downloading.
  54. export function downloadItem(itemId: bigint, highPriority = true): boolean {
  55. try {
  56. return getClient().workshop.download(itemId, highPriority)
  57. } catch {
  58. return false
  59. }
  60. }
  61. export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
  62. await getClient().workshop.unsubscribe(itemId)
  63. }
  64. // ISteamUGC::SetUserItemVote is not bound in steamworks.js, so we call the
  65. // flat C API directly via koffi FFI. libsteam_api.so is already loaded by
  66. // steamworks.js, so dlopen just returns the existing handle. Set up lazily
  67. // after SteamAPI_Init so a load failure can't take down the whole app.
  68. interface UgcFfi {
  69. setUserItemVote: (ugc: unknown, itemId: bigint, voteUp: boolean) => bigint
  70. ugcPtr: unknown
  71. }
  72. let ugcFfi: UgcFfi | null = null
  73. function getUgcFfi(): UgcFfi {
  74. if (!ugcFfi) {
  75. // eslint-disable-next-line @typescript-eslint/no-require-imports
  76. const 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. const steamLib = koffi.load(libPath)
  82. const ugcPtrType = koffi.pointer(koffi.opaque('ISteamUGC'))
  83. const getSteamUGC = steamLib.func('SteamAPI_SteamUGC_v020', ugcPtrType, [])
  84. ugcFfi = {
  85. setUserItemVote: steamLib.func(
  86. 'SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [ugcPtrType, 'uint64', 'bool']
  87. ),
  88. ugcPtr: getSteamUGC()
  89. }
  90. }
  91. return ugcFfi
  92. }
  93. export function voteOnItem(itemId: bigint, voteUp: boolean): void {
  94. getClient() // ensure Steam is initialized before touching the flat API
  95. const ffi = getUgcFfi()
  96. const handle = ffi.setUserItemVote(ffi.ugcPtr, itemId, voteUp)
  97. // k_uAPICallInvalid = 0 means the call failed immediately
  98. if (handle === BigInt(0)) throw new Error('SetUserItemVote failed')
  99. invalidateVoteCache()
  100. }
  101. export function openWorkshopItemOverlay(itemId: bigint): void {
  102. getClient().overlay.activateToWebPage(
  103. `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
  104. )
  105. }
  106. // Cached for the process lifetime; invalidateVoteCache() is called after each vote, and
  107. // startVotedItemsSync() below periodically replaces it wholesale in the background so votes
  108. // cast outside this app (Steam client, community website) show up without a restart.
  109. let votedUpCache: Set<string> | null = null
  110. async function fetchVotedUpItems(): Promise<Set<string>> {
  111. const c = getClient()
  112. const accountId = c.localplayer.getSteamId().accountId
  113. const ids = new Set<string>()
  114. let page = 1
  115. while (true) {
  116. const r = await c.workshop.getUserItems(
  117. page, accountId,
  118. 2 /* VotedUp */, 13 /* UGCType.All, since WE items span multiple subtypes */,
  119. 1 /* CreationOrderDesc */,
  120. { consumer: WE_APP_ID }
  121. )
  122. for (const item of r.items) {
  123. if (item) ids.add(item.publishedFileId.toString())
  124. }
  125. if (r.returnedResults === 0 || ids.size >= r.totalResults) break
  126. page++
  127. }
  128. return ids
  129. }
  130. export async function getVotedUpItemIds(): Promise<string[]> {
  131. if (votedUpCache) return [...votedUpCache]
  132. votedUpCache = await fetchVotedUpItems()
  133. return [...votedUpCache]
  134. }
  135. export function invalidateVoteCache(): void {
  136. votedUpCache = null
  137. }
  138. function sameIds(a: Set<string>, b: Set<string>): boolean {
  139. if (a.size !== b.size) return false
  140. for (const id of a) if (!b.has(id)) return false
  141. return true
  142. }
  143. const VOTE_SYNC_INTERVAL_MS = 3 * 60 * 1000
  144. // Background reconciliation for the vote cache: an in-app vote already updates the renderer
  145. // optimistically (see WorkshopCard/WallpaperCard onLiked handlers), so this isn't on the
  146. // critical path for your own votes - it's what catches up on votes cast elsewhere (Steam
  147. // client, community website) and settles the rare case where SetUserItemVote's async call
  148. // didn't actually land before the optimistic update fired.
  149. let voteSyncTimer: ReturnType<typeof setInterval> | null = null
  150. export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
  151. if (voteSyncTimer) return
  152. voteSyncTimer = setInterval(async () => {
  153. if (!isSteamRunning()) return
  154. try {
  155. const fresh = await fetchVotedUpItems()
  156. const changed = !votedUpCache || !sameIds(votedUpCache, fresh)
  157. votedUpCache = fresh
  158. if (changed) onChange([...fresh])
  159. } catch {
  160. // transient Steam hiccup - just try again next tick
  161. }
  162. }, VOTE_SYNC_INTERVAL_MS)
  163. }
  164. export function getSubscribedItems(): string[] {
  165. return getClient()
  166. .workshop.getSubscribedItems()
  167. .map((id) => id.toString())
  168. }
  169. export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
  170. try {
  171. const info = getClient().workshop.downloadInfo(itemId)
  172. if (!info) return null
  173. const bytesTotal = Number(info.total)
  174. const bytesDownloaded = Number(info.current)
  175. return {
  176. itemId: itemId.toString(),
  177. bytesDownloaded,
  178. bytesTotal,
  179. percentage: bytesTotal > 0 ? Math.round((bytesDownloaded / bytesTotal) * 100) : 0,
  180. status: 'downloading'
  181. }
  182. } catch {
  183. return null
  184. }
  185. }
  186. export function getInstallInfo(itemId: bigint): { folder: string; sizeOnDisk: number } | null {
  187. try {
  188. const info = getClient().workshop.installInfo(itemId)
  189. if (!info) return null
  190. return { folder: info.folder, sizeOnDisk: Number(info.sizeOnDisk) }
  191. } catch {
  192. return null
  193. }
  194. }
  195. // steamworks.js only exposes persona info for the local player/friends, so this
  196. // pulls it from the public community profile XML instead. Cached for the process lifetime.
  197. const authorInfoCache = new Map<string, WorkshopAuthorInfo | null>()
  198. export async function getAuthorInfo(steamId: string): Promise<WorkshopAuthorInfo | null> {
  199. if (authorInfoCache.has(steamId)) return authorInfoCache.get(steamId) ?? null
  200. try {
  201. const res = await fetch(`https://steamcommunity.com/profiles/${steamId}?xml=1`)
  202. const xml = await res.text()
  203. const name = xml.match(/<steamID><!\[CDATA\[([\s\S]*?)\]\]><\/steamID>/)?.[1]
  204. const avatarUrl = xml.match(/<avatarFull><!\[CDATA\[([\s\S]*?)\]\]><\/avatarFull>/)?.[1]
  205. const info = name && avatarUrl ? { steamId, name, avatarUrl } : null
  206. authorInfoCache.set(steamId, info)
  207. return info
  208. } catch {
  209. authorInfoCache.set(steamId, null)
  210. return null
  211. }
  212. }
  213. export function getItemState(itemId: bigint): number {
  214. try {
  215. return getClient().workshop.state(itemId) as number
  216. } catch {
  217. return 0
  218. }
  219. }
  220. // EItemState flags from the Steamworks SDK (isteamugc.h)
  221. const ITEM_STATE_INSTALLED = 4
  222. const ITEM_STATE_NEEDS_UPDATE = 8
  223. const ITEM_STATE_DOWNLOADING = 16
  224. const ITEM_STATE_DOWNLOAD_PENDING = 32
  225. // True for a subscribed item Steam has never actually started fetching - not installed, not
  226. // mid-transfer, not even flagged as needing an update. See downloadItem() above.
  227. export function isItemStuckNeverDownloaded(itemId: bigint): boolean {
  228. const state = getItemState(itemId)
  229. const installed = (state & ITEM_STATE_INSTALLED) !== 0
  230. const inProgress = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING | ITEM_STATE_NEEDS_UPDATE)) !== 0
  231. return !installed && !inProgress
  232. }
  233. export function isItemDownloading(itemId: bigint): boolean {
  234. const state = getItemState(itemId)
  235. return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
  236. }
  237. // NeedsUpdate stays set while Steam hasn't caught the local copy up to the server version. If
  238. // it's set but nothing is actively downloading/pending, Steam gave up on the transfer (e.g. it
  239. // errored out or the disk filled up). A never-downloaded item reports neither bit - treated as
  240. // "downloading" since the caller (scanLibrary) just kicked one off for it.
  241. export function getItemDownloadStatus(itemId: bigint): { downloading: boolean; failed: boolean } {
  242. const state = getItemState(itemId)
  243. const downloading =
  244. (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0 || isItemStuckNeverDownloaded(itemId)
  245. const failed = !downloading && (state & ITEM_STATE_NEEDS_UPDATE) !== 0
  246. return { downloading, failed }
  247. }