Files

776 lines
20 KiB
JavaScript

'use strict';
/** @type {string|null} */
let storeSessionId = null;
/** @type {Record<string, any>|null} */
let userDataCache = null;
/** @type {Record<string, any>|null} */
let userFamilyDataCache = null;
/** @type {Promise<{data: Record<string, any>}|{error: string, data?: Record<string, any>}>|null} */
let userFamilySemaphore = null;
let nextAllowedRequest = 0;
/** @type {chrome|browser} ExtensionApi */
const ExtensionApi = ( () =>
{
if( typeof browser !== 'undefined' && typeof browser.storage !== 'undefined' )
{
return browser;
}
else if( typeof chrome !== 'undefined' && typeof chrome.storage !== 'undefined' )
{
return chrome;
}
throw new Error( 'Did not find appropriate web extensions api' );
} )();
ExtensionApi.runtime.onInstalled.addListener( ( event ) =>
{
if( event.reason === ExtensionApi.runtime.OnInstalledReason.INSTALL )
{
ExtensionApi.tabs.create( {
url: ExtensionApi.runtime.getURL( 'options/options.html' ) + '?welcome=1',
} );
}
} );
ExtensionApi.runtime.onMessage.addListener( ( request, sender, callback ) =>
{
if( !sender || !sender.tab )
{
return false;
}
if( !Object.hasOwn( request, 'contentScriptQuery' ) )
{
return false;
}
switch( request.contentScriptQuery )
{
case 'InvalidateCache': InvalidateCache(); callback(); return true;
case 'FetchSteamUserData': FetchSteamUserData( callback ); return true;
case 'FetchSteamUserFamilyData': FetchSteamUserFamilyData( callback ); return true;
case 'GetApp': GetApp( request.appid, callback ); return true;
case 'GetAppPrice': GetAppPrice( request, callback ); return true;
case 'GetAchievementsGroups': GetAchievementsGroups( request.appid, callback ); return true;
case 'StoreWishlistAdd': StoreWishlistAdd( request.appid, callback ); return true;
case 'StoreWishlistRemove': StoreWishlistRemove( request.appid, callback ); return true;
case 'StoreFollow': StoreFollow( request.appid, callback ); return true;
case 'StoreUnfollow': StoreUnfollow( request.appid, callback ); return true;
case 'StoreIgnore': StoreIgnore( request.appid, callback ); return true;
case 'StoreUnignore': StoreUnignore( request.appid, callback ); return true;
case 'StoreAddToCart': StoreAddToCart( request, callback ); return true;
case 'StoreAddFreeLicense': StoreAddFreeLicense( request, callback ); return true;
case 'StoreRemoveFreeLicense': StoreRemoveFreeLicense( request, callback ); return true;
case 'StoreRequestPlaytestAccess': StoreRequestPlaytestAccess( request, callback ); return true;
}
callback( { success: false, error: 'Unknown query' } );
return false;
} );
function InvalidateCache()
{
userDataCache = null;
userFamilyDataCache = null;
SetLocalOption( 'userdata.cached', Date.now() );
SetLocalOption( 'userfamilydata', '{}' );
}
/**
* @param {(obj: {data: Record<string, any>}|{error: string, data?: Record<string, any>}) => void} callback
*/
async function FetchSteamUserData( callback )
{
if( userDataCache !== null )
{
callback( { data: userDataCache } );
return;
}
const now = Date.now();
const cacheData = await GetLocalOption( { 'userdata.cached': now } );
let cacheTime = cacheData[ 'userdata.cached' ];
if( now > cacheTime + 3600000 )
{
await SetLocalOption( 'userdata.cached', now );
cacheTime = now;
}
const params = new URLSearchParams();
params.set( '_', cacheTime );
try
{
const responseFetch = await fetch(
`https://store.steampowered.com/dynamicstore/userdata/?${params.toString()}`,
{
credentials: 'include',
headers: {
// Pretend we're doing a normal navigation request.
// This will trigger login.steampowered.com redirect flow if user has expired cookies.
Accept: 'text/html',
},
}
);
const response = await responseFetch.json();
if( !response || !response.rgOwnedPackages || !response.rgOwnedPackages.length )
{
throw new Error( 'Are you logged on the Steam Store in this browser?' );
}
// Only keep the data we actually need
userDataCache =
{
rgOwnedPackages: response.rgOwnedPackages || [],
rgOwnedApps: response.rgOwnedApps || [],
rgPackagesInCart: response.rgPackagesInCart || [],
rgAppsInCart: response.rgAppsInCart || [],
rgIgnoredApps: response.rgIgnoredApps || {}, // object, not array
rgIgnoredPackages: response.rgIgnoredPackages || {},
rgFollowedApps: response.rgFollowedApps || [],
rgWishlist: response.rgWishlist || [],
};
callback( { data: userDataCache } );
await SetLocalOption( 'userdata.stored', JSON.stringify( userDataCache ) );
}
catch( error )
{
InvalidateCache();
const data = await GetLocalOption( { 'userdata.stored': false } );
/** @type {{error: string, data?: any}} */
const response =
{
error: error instanceof Error ? error.message : String( error ),
};
if( data[ 'userdata.stored' ] )
{
response.data = JSON.parse( data[ 'userdata.stored' ] );
}
callback( response );
}
}
/**
* @param {(obj: {data: Record<string, any>}|{error: string, data?: Record<string, any>}) => void} callback
*/
async function FetchSteamUserFamilyData( callback )
{
if( userFamilyDataCache !== null )
{
callback( { data: userFamilyDataCache } );
return;
}
if( userFamilySemaphore !== null )
{
callback( await userFamilySemaphore );
return;
}
const now = Date.now();
const cacheData = await GetLocalOption( { userfamilydata: false } );
const cache = cacheData.userfamilydata && JSON.parse( cacheData.userfamilydata );
if( cache && cache.cached && cache.data && now < cache.cached + 21600000 )
{
callback( { data: cache.data } );
return;
}
/** @type {{data: Record<string, any>}|{error: string, data?: Record<string, any>}} */
let callbackResponse = null;
let semaphoreResolve = null;
userFamilySemaphore = new Promise( resolve =>
{
semaphoreResolve = resolve;
} );
try
{
const tokenResponseFetch = await fetch(
`https://store.steampowered.com/pointssummary/ajaxgetasyncconfig`,
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
);
const token = await tokenResponseFetch.json();
if( !token || !token.success || !token.data || !token.data.webapi_token )
{
throw new Error( 'Are you logged on the Steam Store in this browser?' );
}
const paramsSharedLibrary = new URLSearchParams();
paramsSharedLibrary.set( 'access_token', token.data.webapi_token );
paramsSharedLibrary.set( 'family_groupid', '0' ); // family_groupid is ignored
paramsSharedLibrary.set( 'include_excluded', 'true' );
paramsSharedLibrary.set( 'include_free', 'true' );
paramsSharedLibrary.set( 'include_non_games', 'true' );
// the include_own param has no link with its name, if it is false, then it returns only your owned apps.
// if true, it returns your owned apps and the apps from your family.
paramsSharedLibrary.set( 'include_own', 'true' );
const responseFetch = await fetch(
`https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?${paramsSharedLibrary.toString()}`,
{
headers: {
Accept: 'application/json',
}
}
);
const response = await responseFetch.json();
if( !response || !response.response || !response.response.apps )
{
throw new Error( 'Is Steam okay?' );
}
const reduced = response.response.apps.reduce( ( /** @type {any} */ data, /** @type {any} */ app ) =>
{
if( !app.owner_steamids.includes( response.response.owner_steamid ) )
{
if( app.exclude_reason === 0 )
{
data.shared.push( app.appid );
}
}
else
{
data.owned.push( app.appid );
}
return data;
}, {
shared: [],
owned: []
} );
userFamilyDataCache =
{
rgFamilySharedApps: reduced.shared,
rgOwnedApps: reduced.owned,
};
callbackResponse =
{
data: userFamilyDataCache
};
callback( callbackResponse );
await SetLocalOption( 'userfamilydata', JSON.stringify( {
data: userFamilyDataCache,
cached: now
} ) );
}
catch( error )
{
callbackResponse =
{
error: error instanceof Error ? error.message : String( error ),
};
if( cache && cache.data )
{
callbackResponse.data = cache.data;
}
callback( callbackResponse );
}
finally
{
// @ts-ignore - this is assigned inside of a promise
semaphoreResolve( callbackResponse );
userFamilySemaphore = null;
}
}
/**
* @param {Response} response
*/
function GetJsonWithStatusCheck( response )
{
if( !response.ok )
{
if( response.status === 429 )
{
let retryAfter = Number.parseInt( response.headers.get( 'Retry-After' ), 10 );
if( Number.isNaN( retryAfter ) || retryAfter < 1 )
{
retryAfter = 60;
}
nextAllowedRequest = Date.now() + ( retryAfter * 1000 ) + ( Math.random() * 10000 );
console.log( 'Rate limited for', retryAfter, 'seconds, retry after', new Date( nextAllowedRequest ) );
}
const e = new Error( `HTTP ${response.status}` );
e.name = 'ServerError';
throw e;
}
return response.json();
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function GetApp( appid, callback )
{
if( nextAllowedRequest > 0 && Date.now() < nextAllowedRequest )
{
callback( { success: false, error: 'Rate limited' } );
return;
}
const params = new URLSearchParams();
params.set( 'appid', Number.parseInt( appid, 10 ).toString() );
fetch( `https://extension.steamdb.info/api/ExtensionApp/?${params.toString()}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'SteamDB',
},
} )
.then( GetJsonWithStatusCheck )
.then( callback )
.catch( ( error ) => callback( { success: false, error: error.message } ) );
}
/**
* @param {object} obj
* @param {string} obj.appid
* @param {string} obj.currency
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function GetAppPrice( { appid, currency }, callback )
{
if( nextAllowedRequest > 0 && Date.now() < nextAllowedRequest )
{
callback( { success: false, error: 'Rate limited' } );
return;
}
const params = new URLSearchParams();
params.set( 'appid', Number.parseInt( appid, 10 ).toString() );
params.set( 'currency', currency );
fetch( `https://extension.steamdb.info/api/ExtensionAppPrice/?${params.toString()}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'SteamDB',
},
} )
.then( GetJsonWithStatusCheck )
.then( callback )
.catch( ( error ) => callback( { success: false, error: error.message } ) );
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function GetAchievementsGroups( appid, callback )
{
if( nextAllowedRequest > 0 && Date.now() < nextAllowedRequest )
{
callback( { success: false, error: 'Rate limited' } );
return;
}
const params = new URLSearchParams();
params.set( 'appid', Number.parseInt( appid, 10 ).toString() );
fetch( `https://extension.steamdb.info/api/ExtensionGetAchievements/?${params.toString()}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'SteamDB',
},
} )
.then( GetJsonWithStatusCheck )
.then( callback )
.catch( ( error ) => callback( { success: false, error: error.message } ) );
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreWishlistAdd( appid, callback )
{
const formData = new FormData();
formData.set( 'appid', Number.parseInt( appid, 10 ).toString() );
ExecuteStoreApiCall( 'api/addtowishlist', formData, callback );
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreWishlistRemove( appid, callback )
{
const formData = new FormData();
formData.set( 'appid', Number.parseInt( appid, 10 ).toString() );
ExecuteStoreApiCall( 'api/removefromwishlist', formData, callback );
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreFollow( appid, callback )
{
const formData = new FormData();
formData.set( 'appid', Number.parseInt( appid, 10 ).toString() );
ExecuteStoreApiCall( 'explore/followgame/', formData, callback );
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreUnfollow( appid, callback )
{
const formData = new FormData();
formData.set( 'appid', Number.parseInt( appid, 10 ).toString() );
formData.set( 'unfollow', '1' );
ExecuteStoreApiCall( 'explore/followgame/', formData, callback );
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreIgnore( appid, callback )
{
const formData = new FormData();
formData.set( 'appid', Number.parseInt( appid, 10 ).toString() );
formData.set( 'ignore_reason', '0' );
ExecuteStoreApiCall( 'recommended/ignorerecommendation/', formData, callback );
}
/**
* @param {string} appid
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreUnignore( appid, callback )
{
const formData = new FormData();
formData.set( 'appid', Number.parseInt( appid, 10 ).toString() );
formData.set( 'remove', '1' );
ExecuteStoreApiCall( 'recommended/ignorerecommendation/', formData, callback );
}
/**
* @param {Record<string, string>} request
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreAddToCart( request, callback )
{
const formData = new FormData();
formData.set( 'action', 'add_to_cart' );
if( request.subid )
{
formData.set( 'subid', Number.parseInt( request.subid, 10 ).toString() );
}
else if( request.bundleid )
{
formData.set( 'bundleid', Number.parseInt( request.bundleid, 10 ).toString() );
}
else
{
return;
}
ExecuteStoreApiCall( 'cart/addtocart', formData, callback );
}
/**
* @param {Record<string, string>} request
* @param {(obj: {success: true}|{success: false, error: string, resultCode: number}) => void} callback
*/
function StoreAddFreeLicense( request, callback )
{
/**
* @param {any} response
*/
const freeLicenseResponse = ( response ) =>
{
if( Array.isArray( response ) )
{
// This api returns [] on success
callback( { success: true } );
InvalidateCache();
return;
}
const resultCode = response?.purchaseresultdetail ?? null;
let message;
switch( resultCode )
{
case 5: message = 'Steam says this is an invalid package.'; break;
case 9: message = 'This product is already available in your Steam library.'; break;
case 24: message = 'You do not own the required app.'; break;
case 53: message = 'You got rate limited, try again later.'; break;
default: message = resultCode === null
? `There was a problem adding this product to your account. ${response?.error ?? ''}`
: `There was a problem adding this product to your account. PurchaseResultDetail=${resultCode}`;
}
callback( {
success: false,
error: message,
resultCode,
} );
};
if( request.subid )
{
const subid = Number.parseInt( request.subid, 10 );
const formData = new FormData();
formData.set( 'ajax', 'true' );
ExecuteStoreApiCall( `freelicense/addfreelicense/${subid}`, formData, freeLicenseResponse, true );
}
else if( request.bundleid )
{
const bundleid = Number.parseInt( request.bundleid, 10 );
const formData = new FormData();
formData.set( 'ajax', 'true' );
ExecuteStoreApiCall( `freelicense/addfreebundle/${bundleid}`, formData, freeLicenseResponse, true );
}
}
/**
* @param {Record<string, string>} request
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
*/
function StoreRemoveFreeLicense( request, callback )
{
if( request.subid )
{
const subid = Number.parseInt( request.subid, 10 );
const formData = new FormData();
formData.set( 'packageid', subid.toString() );
ExecuteStoreApiCall( 'account/removelicense', formData, callback );
}
}
/**
* @param {Record<string, string>} request
* @param {(obj: {success: boolean, granted: boolean}) => void} callback
*/
function StoreRequestPlaytestAccess( request, callback )
{
/**
* @param {any} response
*/
const playtestResponse = ( response ) =>
{
if( response?.success )
{
const granted = !!response.granted;
callback( { success: true, granted } );
if( granted )
{
InvalidateCache();
}
}
else
{
callback( { success: false, granted: false } );
}
};
if( request.appid )
{
const formData = new FormData();
ExecuteStoreApiCall( `ajaxrequestplaytestaccess/${Number.parseInt( request.appid, 10 )}`, formData, playtestResponse, true );
}
}
/**
* @param {string} path
* @param {FormData} formData
* @param {(obj: {success: true}|{success: false, error: string}) => void} callback
* @param {boolean} rawCallback
*/
function ExecuteStoreApiCall( path, formData, callback, rawCallback = false )
{
GetStoreSessionID( ( session ) =>
{
if( !session.success )
{
callback( session );
return;
}
formData.set( 'sessionid', session.sessionID );
const url = `https://store.steampowered.com/${path}`;
fetch( url, {
credentials: 'include',
method: 'POST',
body: formData,
headers: {
// Specify that we're doing an AJAX request, which will prevent Steam from
// nuking users' cookies (even though it won't do that for POST requests either way)
'X-Requested-With': 'SteamDB',
},
} )
.then( async( response ) =>
{
// If we get 401 Unauthorized, it's likely that the login cookie has expired,
// if that's the case, requesting page to get sessionid again should go through
// login redirect and set a fresh login cookie.
// Or the sessionid simply changed.
// Except for ajaxrequestplaytestaccess which actually can return 401 as part of the api
if( response.status === 401 && !path.startsWith( 'ajaxrequestplaytestaccess/' ) )
{
storeSessionId = null;
}
// Handle possible family view requirement
if( response.status === 403 )
{
const text = await response.clone().text();
if( text.includes( 'data-featuretarget="parentalunlock"' ) || text.includes( 'data-featuretarget="parentalfeaturerequest"' ) )
{
throw new Error( 'Your account is currently under Family View restrictions. You need to exit Family View by entering your PIN on the Steam store and then retry this action.' );
}
}
return response.json();
} )
.then( ( response ) =>
{
if( rawCallback )
{
callback( response );
return;
}
if( path === 'explore/followgame/' )
{
// This API returns just true/false instead of an object
response = {
success: response === true,
};
}
if( response?.success )
{
callback( { success: true } );
InvalidateCache();
}
else
{
callback( { success: false, error: 'Failed to do the action. Are you logged in on the Steam store?\nThis item may not be available in your country.' } );
}
} )
.catch( ( error ) => callback( { success: false, error: error.message } ) );
} );
}
/**
* @param {(obj: {success: true, sessionID: string}|{success: false, error: string}) => void} callback
*/
function GetStoreSessionID( callback )
{
if( storeSessionId )
{
callback( { success: true, sessionID: storeSessionId } );
return;
}
const url = 'https://store.steampowered.com/account/preferences';
fetch( url, {
credentials: 'include',
headers: {
// We have to specify that we're doing a normal request (as if the user was navigating).
// This will trigger login.steampowered.com redirect flow if user has expired cookies.
Accept: 'text/html',
},
} )
.then( ( response ) => response.text() )
.then( ( response ) =>
{
const session = response.match( /g_sessionID = "(\w+)";/ );
if( session?.[ 1 ] )
{
storeSessionId = session[ 1 ];
callback( { success: true, sessionID: session[ 1 ] } );
}
else
{
callback( {
success: false,
error: response.includes( 'login' )
? 'Failed to fetch sessionid. It does not look like you are logged in to the Steam store.'
: 'Failed to fetch sessionid. Try reloading the page.',
} );
}
} )
.catch( ( error ) => callback( { success: false, error: error.message } ) );
}
/**
* @param {{[key: string]: any}} items
* @returns {Promise<{[key: string]: any}>}
*/
function GetLocalOption( items )
{
return ExtensionApi.storage.local.get( items );
}
/**
* @param {string} option
* @param {any} value
*/
function SetLocalOption( option, value )
{
/** @type {{ [key: string]: any }} */
const obj = {};
obj[ option ] = value;
return ExtensionApi.storage.local.set( obj );
}