steam.service.ts 4.7 KB

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