| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- import * as path from 'path'
- import Store from 'electron-store'
- import { app } from 'electron'
- 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.
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- type Koffi = typeof import('koffi')
- let steamLibHandle: ReturnType<Koffi['load']> | null = null
- function getSteamLib(): ReturnType<Koffi['load']> {
- if (!steamLibHandle) {
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const koffi: 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')
- steamLibHandle = koffi.load(libPath)
- }
- return steamLibHandle
- }
- 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: Koffi = require('koffi')
- const steamLib = getSteamLib()
- 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
- }
- let isLoggedOnFn: (() => boolean) | null = null
- function isSteamOnline(): boolean {
- if (!isLoggedOnFn) {
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const koffi: Koffi = require('koffi')
- const steamLib = getSteamLib()
- const userPtrType = koffi.pointer(koffi.opaque('ISteamUser'))
- const getSteamUser = steamLib.func('SteamAPI_SteamUser_v023', userPtrType, [])
- const bLoggedOn = steamLib.func('SteamAPI_ISteamUser_BLoggedOn', 'bool', [userPtrType])
- const userPtr = getSteamUser()
- isLoggedOnFn = () => bLoggedOn(userPtr)
- }
- return isLoggedOnFn()
- }
- function sleep(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms))
- }
- // steamworks.js async calls have no timeout and can hang forever if Steam stalls
- function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
- return new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
- promise.then(
- (value) => {
- clearTimeout(timer)
- resolve(value)
- },
- (err) => {
- clearTimeout(timer)
- reject(err)
- }
- )
- })
- }
- // steamworks.js has no vote bindings; this app's fork adds them (patches/steamworks-vote/), feature-detected so unpatched builds still work
- interface PatchedVoteWorkshop {
- voteItem(itemId: bigint, voteUp: boolean): Promise<void>
- getUserVote(itemId: bigint): Promise<{ votedUp: boolean; votedDown: boolean; voteSkipped: boolean }>
- }
- function getPatchedVoteApi(client: ReturnType<typeof getClient>): PatchedVoteWorkshop | null {
- const workshop = client.workshop as unknown as Partial<PatchedVoteWorkshop>
- if (typeof workshop.voteItem === 'function' && typeof workshop.getUserVote === 'function') {
- return workshop as PatchedVoteWorkshop
- }
- return null
- }
- export async function checkUserVote(
- itemId: bigint
- ): Promise<{ votedUp: boolean; votedDown: boolean } | null> {
- const patched = getPatchedVoteApi(getClient())
- if (!patched) return null
- const result = await patched.getUserVote(itemId)
- return { votedUp: result.votedUp, votedDown: result.votedDown }
- }
- export function voteOnItem(itemId: bigint, voteUp: boolean): bigint {
- 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')
- return handle
- }
- export function openWorkshopItemOverlay(itemId: bigint): void {
- getClient().overlay.activateToWebPage(
- `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
- )
- }
- const USER_ITEM_TYPE = 13 // UGCType.All, since WE items span multiple subtypes
- const CREATION_ORDER_DESC = 1
- const VOTED_UP = 2
- const VOTED_DOWN = 3
- const PAGE_FETCH_TIMEOUT_MS = 8000
- const VOTE_TIMEOUT_MS = 15000
- async function getUserItemsPage(
- accountId: number,
- listType: number,
- page: number,
- retries = 2
- ): Promise<{ items: Array<{ publishedFileId: bigint } | null | undefined>; returnedResults: number; totalResults: number }> {
- const c = getClient()
- for (let attempt = 0; ; attempt++) {
- try {
- return await withTimeout(
- c.workshop.getUserItems(
- page, accountId, listType, USER_ITEM_TYPE, CREATION_ORDER_DESC, { consumer: WE_APP_ID }
- ),
- PAGE_FETCH_TIMEOUT_MS,
- `getUserItems page ${page}`
- )
- } catch (err) {
- if (attempt >= retries) throw err
- await sleep(300 * (attempt + 1))
- }
- }
- }
- interface VoteCacheStore {
- accountId: number | null
- up: string[]
- missing: string[]
- lastFullSync: number
- pending: Record<string, { up: boolean; at: number }>
- }
- const voteStore = new Store<VoteCacheStore>({
- name: 'vote-cache',
- defaults: { accountId: null, up: [], missing: [], lastFullSync: 0, pending: {} }
- })
- let votedUpCache = new Set<string>(voteStore.get('up'))
- // an id must be missing on two consecutive full crawls before it is dropped, to absorb pagination drift
- let missingLastCycle = new Set<string>(voteStore.get('missing'))
- // Steam's voted-up list lags behind the vote itself, so local votes stay authoritative until the crawl agrees or this window runs out
- const PENDING_VOTE_TTL_MS = 15 * 60 * 1000
- const pendingVotes = new Map<string, { up: boolean; at: number }>(
- Object.entries(voteStore.get('pending')).filter(([, v]) => Date.now() - v.at <= PENDING_VOTE_TTL_MS)
- )
- function applyPendingVotes(fresh: Set<string>): Set<string> {
- const now = Date.now()
- const merged = new Set(fresh)
- for (const [id, vote] of pendingVotes) {
- if (fresh.has(id) === vote.up || now - vote.at > PENDING_VOTE_TTL_MS) {
- pendingVotes.delete(id)
- continue
- }
- if (vote.up) merged.add(id)
- else merged.delete(id)
- }
- return merged
- }
- const PERSIST_DELAY_MS = 3000
- let persistTimer: ReturnType<typeof setTimeout> | null = null
- let persistDirty = false
- function persistVoteCache(): void {
- persistDirty = true
- if (persistTimer) return
- persistTimer = setTimeout(flushVoteCache, PERSIST_DELAY_MS)
- }
- function flushVoteCache(): void {
- if (persistTimer) {
- clearTimeout(persistTimer)
- persistTimer = null
- }
- if (!persistDirty) return
- persistDirty = false
- voteStore.set('up', [...votedUpCache])
- voteStore.set('missing', [...missingLastCycle])
- voteStore.set('pending', Object.fromEntries(pendingVotes))
- }
- app.on('will-quit', flushVoteCache)
- const VOTE_FETCH_CONCURRENCY = 3
- const PAGE_PACING_MS = 150
- // list sort orders use the item's publish date, not the vote date, so the only way to get the full liked list is to crawl all of it
- async function fetchVotedItems(
- accountId: number,
- listType: number,
- onPage: (ids: string[]) => void
- ): Promise<Set<string>> {
- const ids = new Set<string>()
- const take = (items: Array<{ publishedFileId: bigint } | null | undefined>): void => {
- const pageIds: string[] = []
- for (const item of items) if (item) pageIds.push(item.publishedFileId.toString())
- for (const id of pageIds) ids.add(id)
- onPage(pageIds)
- }
- const first = await getUserItemsPage(accountId, listType, 1)
- take(first.items)
- const pageSize = first.returnedResults
- if (pageSize <= 0 || ids.size >= first.totalResults) return ids
- const totalPages = Math.ceil(first.totalResults / pageSize)
- const remainingPages = Array.from({ length: Math.max(0, totalPages - 1) }, (_, i) => i + 2)
- let cursor = 0
- async function worker(): Promise<void> {
- while (cursor < remainingPages.length) {
- const page = remainingPages[cursor++]
- const r = await getUserItemsPage(accountId, listType, page)
- take(r.items)
- await sleep(PAGE_PACING_MS)
- }
- }
- await Promise.all(
- Array.from({ length: Math.min(VOTE_FETCH_CONCURRENCY, remainingPages.length) }, worker)
- )
- return ids
- }
- const FULL_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000
- const SYNC_CHECK_INTERVAL_MS = 10 * 60 * 1000
- const SYNC_STARTUP_DELAY_MS = 20 * 1000
- const PROGRESS_NOTIFY_INTERVAL_MS = 15 * 1000
- let votedIdsChangeListener: ((ids: string[]) => void) | null = null
- let syncInFlight = false
- let lastSyncFailure = 0
- function notifyVotedIds(): void {
- votedIdsChangeListener?.([...votedUpCache])
- }
- async function runFullSync(): Promise<void> {
- const accountId = getClient().localplayer.getSteamId().accountId
- if (voteStore.get('accountId') !== null && voteStore.get('accountId') !== accountId) {
- votedUpCache = new Set()
- missingLastCycle = new Set()
- pendingVotes.clear()
- notifyVotedIds()
- }
- voteStore.set('accountId', accountId)
- let lastNotify = Date.now()
- const seen = await fetchVotedItems(accountId, VOTED_UP, (pageIds) => {
- let added = false
- for (const id of pageIds) {
- if (!votedUpCache.has(id) && pendingVotes.get(id)?.up !== false) {
- votedUpCache.add(id)
- added = true
- }
- }
- if (!added) return
- persistVoteCache()
- if (Date.now() - lastNotify >= PROGRESS_NOTIFY_INTERVAL_MS) {
- lastNotify = Date.now()
- notifyVotedIds()
- }
- })
- const fresh = applyPendingVotes(seen)
- const merged = new Set(fresh)
- const missingThisCycle = new Set<string>()
- for (const id of votedUpCache) {
- if (fresh.has(id)) continue
- if (missingLastCycle.has(id)) continue
- merged.add(id)
- missingThisCycle.add(id)
- }
- const changed = !sameIds(votedUpCache, merged)
- missingLastCycle = missingThisCycle
- votedUpCache = merged
- voteStore.set('lastFullSync', Date.now())
- persistVoteCache()
- if (changed) notifyVotedIds()
- console.log(`[Steam] Liked list synced: ${votedUpCache.size} items`)
- }
- function maybeRunFullSync(): void {
- if (syncInFlight || !isSteamRunning()) return
- const now = Date.now()
- if (now - voteStore.get('lastFullSync') < FULL_SYNC_INTERVAL_MS) return
- if (now - lastSyncFailure < SYNC_CHECK_INTERVAL_MS) return
- syncInFlight = true
- runFullSync()
- .catch((err) => {
- lastSyncFailure = Date.now()
- console.warn('[Steam] Liked list sync failed, will retry later:', err?.message ?? err)
- })
- .finally(() => {
- syncInFlight = false
- })
- }
- function recordVote(idStr: string, voteUp: boolean): void {
- pendingVotes.set(idStr, { up: voteUp, at: Date.now() })
- if (voteUp) votedUpCache.add(idStr)
- else votedUpCache.delete(idStr)
- missingLastCycle.delete(idStr)
- persistVoteCache()
- }
- // Without the patched steamworks.js, success is reported once SetUserItemVote is enqueued: its result can't be read back,
- // because steamworks.js's manual dispatch loop consumes every call result on the shared Steam pipe first.
- export async function voteOnItemAndConfirm(itemId: bigint, voteUp: boolean): Promise<boolean> {
- if (!isSteamRunning()) throw new Error('Steam is not running - start Steam to like wallpapers')
- const client = getClient()
- if (!isSteamOnline()) throw new Error('Steam is offline - check your internet connection and try again')
- const idStr = itemId.toString()
- const patched = getPatchedVoteApi(client)
- if (patched) {
- await withTimeout(patched.voteItem(itemId, voteUp), VOTE_TIMEOUT_MS, 'Vote').catch((err) => {
- throw new Error(`Could not reach Steam to ${voteUp ? 'like' : 'dislike'} this wallpaper: ${err?.message ?? err}`)
- })
- recordVote(idStr, voteUp)
- return true
- }
- voteOnItem(itemId, voteUp)
- recordVote(idStr, voteUp)
- return true
- }
- 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
- }
- export async function getVotedUpItemIds(): Promise<string[]> {
- return [...votedUpCache]
- }
- let voteSyncTimer: ReturnType<typeof setInterval> | null = null
- export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
- votedIdsChangeListener = onChange
- if (voteSyncTimer) return
- setTimeout(maybeRunFullSync, SYNC_STARTUP_DELAY_MS)
- voteSyncTimer = setInterval(maybeRunFullSync, SYNC_CHECK_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 }
- }
|