steam.service.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import * as path from 'path'
  2. import { WE_APP_ID } from '@shared/constants'
  3. import type { DownloadProgressEvent } from '@shared/types'
  4. let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
  5. export function initSteam(): boolean {
  6. try {
  7. // steamworks.js is a native module and must be required at runtime
  8. // eslint-disable-next-line @typescript-eslint/no-require-imports
  9. const steamworks: typeof import('steamworks.js') = require('steamworks.js')
  10. client = steamworks.init(WE_APP_ID)
  11. console.log('[Steam] Initialized')
  12. return true
  13. } catch (err) {
  14. console.warn('[Steam] Init failed (Steam may not be running):', err)
  15. return false
  16. }
  17. }
  18. export function isSteamRunning(): boolean {
  19. if (!client) initSteam()
  20. return client !== null
  21. }
  22. export function getClient() {
  23. if (!client) initSteam()
  24. if (!client) throw new Error('Steam not initialized')
  25. return client
  26. }
  27. export async function subscribeToItem(itemId: bigint): Promise<void> {
  28. await getClient().workshop.subscribe(itemId)
  29. }
  30. export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
  31. await getClient().workshop.unsubscribe(itemId)
  32. }
  33. // ISteamUGC::SetUserItemVote is not bound in steamworks.js, so we call the
  34. // flat C API directly via koffi FFI. libsteam_api.so is already loaded by
  35. // steamworks.js, so dlopen just returns the existing handle. Set up lazily
  36. // after SteamAPI_Init so a load failure can't take down the whole app.
  37. interface UgcFfi {
  38. setUserItemVote: (ugc: unknown, itemId: bigint, voteUp: boolean) => bigint
  39. ugcPtr: unknown
  40. }
  41. let ugcFfi: UgcFfi | null = null
  42. function getUgcFfi(): UgcFfi {
  43. if (!ugcFfi) {
  44. // eslint-disable-next-line @typescript-eslint/no-require-imports
  45. const koffi = require('koffi')
  46. const libPath = path.join(
  47. path.dirname(require.resolve('steamworks.js')),
  48. 'dist', 'linux64', 'libsteam_api.so'
  49. )
  50. const steamLib = koffi.load(libPath)
  51. const ugcPtrType = koffi.pointer(koffi.opaque('ISteamUGC'))
  52. const getSteamUGC = steamLib.func('SteamAPI_SteamUGC_v020', ugcPtrType, [])
  53. ugcFfi = {
  54. setUserItemVote: steamLib.func(
  55. 'SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [ugcPtrType, 'uint64', 'bool']
  56. ),
  57. ugcPtr: getSteamUGC()
  58. }
  59. }
  60. return ugcFfi
  61. }
  62. export function voteOnItem(itemId: bigint, voteUp: boolean): void {
  63. getClient() // ensure Steam is initialized before touching the flat API
  64. const ffi = getUgcFfi()
  65. const handle = ffi.setUserItemVote(ffi.ugcPtr, itemId, voteUp)
  66. // k_uAPICallInvalid = 0 means the call failed immediately
  67. if (handle === BigInt(0)) throw new Error('SetUserItemVote failed')
  68. invalidateVoteCache()
  69. }
  70. export function openWorkshopItemOverlay(itemId: bigint): void {
  71. getClient().overlay.activateToWebPage(
  72. `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
  73. )
  74. }
  75. // Fetched via getUserItems(VotedUp) and cached for the process lifetime;
  76. // invalidateVoteCache() is called after each vote.
  77. let votedUpCache: Set<string> | null = null
  78. export async function getVotedUpItemIds(): Promise<string[]> {
  79. if (votedUpCache) return [...votedUpCache]
  80. const c = getClient()
  81. const accountId = c.localplayer.getSteamId().accountId
  82. const ids = new Set<string>()
  83. let page = 1
  84. while (true) {
  85. const r = await c.workshop.getUserItems(
  86. page, accountId,
  87. 2 /* VotedUp */, 0 /* Items */,
  88. 1 /* CreationOrderDesc */,
  89. { consumer: WE_APP_ID }
  90. )
  91. for (const item of r.items) {
  92. if (item) ids.add(item.publishedFileId.toString())
  93. }
  94. if (r.returnedResults === 0 || ids.size >= r.totalResults) break
  95. page++
  96. }
  97. votedUpCache = ids
  98. return [...ids]
  99. }
  100. export function invalidateVoteCache(): void {
  101. votedUpCache = null
  102. }
  103. export function getSubscribedItems(): string[] {
  104. return getClient()
  105. .workshop.getSubscribedItems()
  106. .map((id) => id.toString())
  107. }
  108. export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
  109. try {
  110. const info = getClient().workshop.downloadInfo(itemId)
  111. if (!info) return null
  112. const bytesTotal = Number(info.total)
  113. const bytesDownloaded = Number(info.current)
  114. return {
  115. itemId: itemId.toString(),
  116. bytesDownloaded,
  117. bytesTotal,
  118. percentage: bytesTotal > 0 ? Math.round((bytesDownloaded / bytesTotal) * 100) : 0,
  119. status: 'downloading'
  120. }
  121. } catch {
  122. return null
  123. }
  124. }
  125. export function getInstallInfo(itemId: bigint): { folder: string; sizeOnDisk: number } | null {
  126. try {
  127. const info = getClient().workshop.installInfo(itemId)
  128. if (!info) return null
  129. return { folder: info.folder, sizeOnDisk: Number(info.sizeOnDisk) }
  130. } catch {
  131. return null
  132. }
  133. }
  134. export function getItemState(itemId: bigint): number {
  135. try {
  136. return getClient().workshop.state(itemId) as number
  137. } catch {
  138. return 0
  139. }
  140. }
  141. // EItemState flags from the Steamworks SDK (isteamugc.h)
  142. const ITEM_STATE_DOWNLOAD_PENDING = 32
  143. const ITEM_STATE_DOWNLOADING = 16
  144. export function isItemDownloading(itemId: bigint): boolean {
  145. const state = getItemState(itemId)
  146. return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
  147. }