| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- import * as path from 'path'
- import { WE_APP_ID, STANDALONE_APP_ID } from '@shared/constants'
- import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
- import { getSteamIdentity } from './config.service'
- let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
- // Steam not running is a normal, common state (this app works as a local library manager
- // without it). Every isSteamRunning()/getClient() call used to retry the native init and log a
- // warning, so anything that polls (react-query refetches, library scans, ...) turned "Steam is
- // closed" into a continuous stream of console warnings. Only actually retry - and only warn -
- // once per cooldown window, and only log the first failure of a run.
- const RETRY_COOLDOWN_MS = 15000
- let lastInitAttempt = 0
- let hasWarnedThisOutage = false
- export function initSteam(): boolean {
- lastInitAttempt = Date.now()
- try {
- // steamworks.js is a native module and must be required at runtime
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const steamworks: typeof import('steamworks.js') = require('steamworks.js')
- const appId = getSteamIdentity() === 'standalone' ? STANDALONE_APP_ID : WE_APP_ID
- client = steamworks.init(appId)
- hasWarnedThisOutage = false
- console.log(`[Steam] Initialized (app id ${appId})`)
- return true
- } catch (err) {
- client = null
- if (!hasWarnedThisOutage) {
- hasWarnedThisOutage = true
- console.warn('[Steam] Init failed (Steam may not be running):', err)
- }
- return false
- }
- }
- function ensureClient(): void {
- if (client) return
- if (Date.now() - lastInitAttempt < RETRY_COOLDOWN_MS) return
- initSteam()
- }
- export function isSteamRunning(): boolean {
- ensureClient()
- return client !== null
- }
- export function getClient() {
- ensureClient()
- if (!client) throw new Error('Steam not initialized')
- return client
- }
- export async function subscribeToItem(itemId: bigint): Promise<void> {
- await getClient().workshop.subscribe(itemId)
- }
- // Steam's subscribe doesn't reliably auto-download (seen with items subscribed via the Workshop
- // website rather than the Steam client) - items can sit at bare "Subscribed" forever. This is the
- // reliable kick; a no-op if already installed or downloading.
- export function downloadItem(itemId: bigint, highPriority = true): boolean {
- try {
- return getClient().workshop.download(itemId, highPriority)
- } catch {
- return false
- }
- }
- export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
- await getClient().workshop.unsubscribe(itemId)
- }
- // ISteamUGC::SetUserItemVote is not bound in steamworks.js, so we call the
- // flat C API directly via koffi FFI. libsteam_api.so is already loaded by
- // steamworks.js, so dlopen just returns the existing handle. Set up lazily
- // after SteamAPI_Init so a load failure can't take down the whole app.
- interface UgcFfi {
- setUserItemVote: (ugc: unknown, itemId: bigint, voteUp: boolean) => bigint
- ugcPtr: unknown
- }
- let ugcFfi: UgcFfi | null = null
- function getUgcFfi(): UgcFfi {
- if (!ugcFfi) {
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const koffi = require('koffi')
- const libPath = path.join(
- path.dirname(require.resolve('steamworks.js')),
- 'dist', 'linux64', 'libsteam_api.so'
- ).replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked')
- const steamLib = koffi.load(libPath)
- const ugcPtrType = koffi.pointer(koffi.opaque('ISteamUGC'))
- const getSteamUGC = steamLib.func('SteamAPI_SteamUGC_v020', ugcPtrType, [])
- ugcFfi = {
- setUserItemVote: steamLib.func(
- 'SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [ugcPtrType, 'uint64', 'bool']
- ),
- ugcPtr: getSteamUGC()
- }
- }
- return ugcFfi
- }
- export function voteOnItem(itemId: bigint, voteUp: boolean): void {
- getClient() // ensure Steam is initialized before touching the flat API
- const ffi = getUgcFfi()
- const handle = ffi.setUserItemVote(ffi.ugcPtr, itemId, voteUp)
- // k_uAPICallInvalid = 0 means the call failed immediately
- if (handle === BigInt(0)) throw new Error('SetUserItemVote failed')
- invalidateVoteCache()
- }
- export function openWorkshopItemOverlay(itemId: bigint): void {
- getClient().overlay.activateToWebPage(
- `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
- )
- }
- // Cached for the process lifetime; invalidateVoteCache() is called after each vote, and
- // startVotedItemsSync() below periodically replaces it wholesale in the background so votes
- // cast outside this app (Steam client, community website) show up without a restart.
- let votedUpCache: Set<string> | null = null
- async function fetchVotedUpItems(): Promise<Set<string>> {
- const c = getClient()
- const accountId = c.localplayer.getSteamId().accountId
- const ids = new Set<string>()
- let page = 1
- while (true) {
- const r = await c.workshop.getUserItems(
- page, accountId,
- 2 /* VotedUp */, 13 /* UGCType.All, since WE items span multiple subtypes */,
- 1 /* CreationOrderDesc */,
- { consumer: WE_APP_ID }
- )
- for (const item of r.items) {
- if (item) ids.add(item.publishedFileId.toString())
- }
- if (r.returnedResults === 0 || ids.size >= r.totalResults) break
- page++
- }
- return ids
- }
- export async function getVotedUpItemIds(): Promise<string[]> {
- if (votedUpCache) return [...votedUpCache]
- votedUpCache = await fetchVotedUpItems()
- return [...votedUpCache]
- }
- export function invalidateVoteCache(): void {
- votedUpCache = null
- }
- function sameIds(a: Set<string>, b: Set<string>): boolean {
- if (a.size !== b.size) return false
- for (const id of a) if (!b.has(id)) return false
- return true
- }
- const VOTE_SYNC_INTERVAL_MS = 3 * 60 * 1000
- // Background reconciliation for the vote cache: an in-app vote already updates the renderer
- // optimistically (see WorkshopCard/WallpaperCard onLiked handlers), so this isn't on the
- // critical path for your own votes - it's what catches up on votes cast elsewhere (Steam
- // client, community website) and settles the rare case where SetUserItemVote's async call
- // didn't actually land before the optimistic update fired.
- let voteSyncTimer: ReturnType<typeof setInterval> | null = null
- export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
- if (voteSyncTimer) return
- voteSyncTimer = setInterval(async () => {
- if (!isSteamRunning()) return
- try {
- const fresh = await fetchVotedUpItems()
- const changed = !votedUpCache || !sameIds(votedUpCache, fresh)
- votedUpCache = fresh
- if (changed) onChange([...fresh])
- } catch {
- // transient Steam hiccup - just try again next tick
- }
- }, VOTE_SYNC_INTERVAL_MS)
- }
- export function getSubscribedItems(): string[] {
- return getClient()
- .workshop.getSubscribedItems()
- .map((id) => id.toString())
- }
- export function getDownloadInfo(itemId: bigint): DownloadProgressEvent | null {
- try {
- const info = getClient().workshop.downloadInfo(itemId)
- if (!info) return null
- const bytesTotal = Number(info.total)
- const bytesDownloaded = Number(info.current)
- return {
- itemId: itemId.toString(),
- bytesDownloaded,
- bytesTotal,
- percentage: bytesTotal > 0 ? Math.round((bytesDownloaded / bytesTotal) * 100) : 0,
- status: 'downloading'
- }
- } catch {
- return null
- }
- }
- export function getInstallInfo(itemId: bigint): { folder: string; sizeOnDisk: number } | null {
- try {
- const info = getClient().workshop.installInfo(itemId)
- if (!info) return null
- return { folder: info.folder, sizeOnDisk: Number(info.sizeOnDisk) }
- } catch {
- return null
- }
- }
- // steamworks.js only exposes persona info for the local player/friends, so this
- // pulls it from the public community profile XML instead. Cached for the process lifetime.
- const authorInfoCache = new Map<string, WorkshopAuthorInfo | null>()
- export async function getAuthorInfo(steamId: string): Promise<WorkshopAuthorInfo | null> {
- if (authorInfoCache.has(steamId)) return authorInfoCache.get(steamId) ?? null
- try {
- const res = await fetch(`https://steamcommunity.com/profiles/${steamId}?xml=1`)
- const xml = await res.text()
- const name = xml.match(/<steamID><!\[CDATA\[([\s\S]*?)\]\]><\/steamID>/)?.[1]
- const avatarUrl = xml.match(/<avatarFull><!\[CDATA\[([\s\S]*?)\]\]><\/avatarFull>/)?.[1]
- const info = name && avatarUrl ? { steamId, name, avatarUrl } : null
- authorInfoCache.set(steamId, info)
- return info
- } catch {
- authorInfoCache.set(steamId, null)
- return null
- }
- }
- export function getItemState(itemId: bigint): number {
- try {
- return getClient().workshop.state(itemId) as number
- } catch {
- return 0
- }
- }
- // EItemState flags from the Steamworks SDK (isteamugc.h)
- const ITEM_STATE_INSTALLED = 4
- const ITEM_STATE_NEEDS_UPDATE = 8
- const ITEM_STATE_DOWNLOADING = 16
- const ITEM_STATE_DOWNLOAD_PENDING = 32
- // True for a subscribed item Steam has never actually started fetching - not installed, not
- // mid-transfer, not even flagged as needing an update. See downloadItem() above.
- export function isItemStuckNeverDownloaded(itemId: bigint): boolean {
- const state = getItemState(itemId)
- const installed = (state & ITEM_STATE_INSTALLED) !== 0
- const inProgress = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING | ITEM_STATE_NEEDS_UPDATE)) !== 0
- return !installed && !inProgress
- }
- export function isItemDownloading(itemId: bigint): boolean {
- const state = getItemState(itemId)
- return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
- }
- // NeedsUpdate stays set while Steam hasn't caught the local copy up to the server version. If
- // it's set but nothing is actively downloading/pending, Steam gave up on the transfer (e.g. it
- // errored out or the disk filled up). A never-downloaded item reports neither bit - treated as
- // "downloading" since the caller (scanLibrary) just kicked one off for it.
- export function getItemDownloadStatus(itemId: bigint): { downloading: boolean; failed: boolean } {
- const state = getItemState(itemId)
- const downloading =
- (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0 || isItemStuckNeverDownloaded(itemId)
- const failed = !downloading && (state & ITEM_STATE_NEEDS_UPDATE) !== 0
- return { downloading, failed }
- }
|