steam.service.ts 4.5 KB

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