Last active
September 26, 2025 15:02
-
-
Save saiteja09/ef9047d9b5bf63eab55e13d83cd46fb4 to your computer and use it in GitHub Desktop.
Widget for Yearly Xbox GamerScore Tracking for use with Scriptable app in iOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| let xbox_refreshtoken = null | |
| let xbox_clientid = null | |
| let xbox_clientsecret = null | |
| let xbox_credential_base64 = null | |
| let xbox_authorization = null | |
| let xbox_id = null | |
| let xbox_profileurl = 'https://peoplehub.xboxlive.com/users/me/people/xuids(<xid>)/decoration/detail,preferredColor,presenceDetail,multiplayerSummary' | |
| let xbox_titleHistoryurl = 'https://titlehub.xboxlive.com/users/xuid(<xid>)/titles/titleHistory/decoration/GamePass,TitleHistory,Achievement,Stats' | |
| let xbox_achievementsurl = 'https://achievements.xboxlive.com/users/xuid(<xid>)/achievements?orderBy=UnlockTime&unlockedOnly=true' | |
| const xbox_tokenurl = 'https://login.live.com/oauth20_token.srf' | |
| const xbox_live_authurl = 'https://user.auth.xboxlive.com/user/authenticate' | |
| const xbox_live_xstsurl = 'https://xsts.auth.xboxlive.com/xsts/authorize' | |
| const xbox_logourl = 'https://user-images.githubusercontent.com/8601809/202868884-b3b47156-8314-4022-ab96-aa1168437464.png' | |
| const xbox_gs_logourl = 'https://user-images.githubusercontent.com/8601809/202863495-ae6c706b-66d9-47a8-b035-46c70dffec74.png' | |
| const xbox_ach_logourl = 'https://user-images.githubusercontent.com/8601809/202863432-84bae84a-7025-4705-b2d9-8171f13ffb8b.png' | |
| const quick_chart_url = 'https://quickchart.io/chart' | |
| let numOfAchByMnth = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] | |
| let sumofGscByMnth = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] | |
| let sumGamerScore = 0 | |
| let sumOfAch = 0 | |
| // Read Refresh Token from Keychain | |
| if (Keychain.contains('xbox_refreshtoken')) { | |
| xbox_refreshtoken = Keychain.get('xbox_refreshtoken') | |
| } else { | |
| console.error('Refresh Token not found in Keychain. Please store Refresh Token in the key \'xbox_refreshtoken\'') | |
| Script.complete() | |
| } | |
| // Read Client ID from Keychain | |
| if (Keychain.contains('xbox_clientid')) { | |
| xbox_clientid = Keychain.get('xbox_clientid') | |
| } else { | |
| console.error('Client ID not found in Keychain. Please store Client ID in the key \'xbox_clientid\'') | |
| Script.complete() | |
| } | |
| // Read Client Secret from Keychain | |
| if (Keychain.contains('xbox_clientsecret')) { | |
| xbox_clientsecret = Keychain.get('xbox_clientsecret') | |
| } else { | |
| console.error('Client Secret not found in Keychain. Please store Client Secret in the key \'xbox_clientid\'') | |
| Script.complete() | |
| } | |
| //Base 64 for ClientID and Client Secret | |
| xbox_credential_base64 = 'Basic ' + btoa(xbox_clientid + ':' + xbox_clientsecret) | |
| //Start Authentication | |
| await authenticateWithXbox() | |
| uPResp = await getUserProfile() | |
| //Widget Rendering | |
| xboxWidget = await renderWidget() | |
| if (config.runsInWidget) { | |
| Script.setWidget(xboxWidget) | |
| } else { | |
| xboxWidget.presentLarge() | |
| } | |
| Script.complete() | |
| // Main function for Rendering widget | |
| async function renderWidget() { | |
| widget = new ListWidget() | |
| widget.backgroundColor = new Color('#107C10') | |
| firstStack = widget.addStack() | |
| firstStack.centerAlignContent() | |
| xboxlogo = firstStack.addImage(await getImageFromURL(xbox_logourl)) | |
| xboxlogo.tintColor = Color.white() | |
| xboxlogo.imageSize = new Size(100, 40) | |
| firstStack.addSpacer() | |
| uPImage = firstStack.addImage(await getImageFromURL(uPResp.people[0].displayPicRaw)) | |
| uPImage.imageSize = new Size(30, 30) | |
| uPImage.cornerRadius = 100 | |
| firstStack.addSpacer(5) | |
| uPGamerTag = firstStack.addText(uPResp.people[0].gamertag) | |
| uPGamerTag.leftAlignText() | |
| uPGamerTag.font = Font.boldRoundedSystemFont(15) | |
| uPGamerTag.textColor = Color.white() | |
| secondStack = widget.addStack(10) | |
| secondStack.centerAlignContent() | |
| secondStack.addText(' ') | |
| secondStack.addSpacer() | |
| tgsImage = secondStack.addImage(await getImageFromURL(xbox_gs_logourl)) | |
| tgsImage.tintColor = Color.white() | |
| tgsImage.imageSize = new Size(15, 15) | |
| secondStack.addSpacer(5) | |
| tgsTxt = secondStack.addText(uPResp.people[0].gamerScore) | |
| tgsTxt.font = Font.boldRoundedSystemFont(14) | |
| tgsTxt.textColor = Color.white() | |
| secondStack.setPadding(0, 0, 10, 0) | |
| thirdStack = widget.addStack() | |
| thirdStack.centerAlignContent() | |
| thirdStack.addSpacer() | |
| gsBMnthTxt = thirdStack.addText('Yearly GamerScore Tracker') | |
| gsBMnthTxt.font = Font.boldRoundedSystemFont(12) | |
| gsBMnthTxt.textColor = Color.white() | |
| thirdStack.addSpacer() | |
| thirdStack.setPadding(0, 0, 10, 0) | |
| await getAchievementsByMonth() | |
| fourthStack = widget.addStack() | |
| fourthStack.addImage(await getGamerScoreChart()) | |
| fifthStack = widget.addStack() | |
| fifthStack.setPadding(10, 0, 0, 0) | |
| tgwTxt = fifthStack.addText("Total GamerScore Won \n" + sumGamerScore.toString()) | |
| tgwTxt.font = Font.boldMonospacedSystemFont(12) | |
| tgwTxt.textColor = Color.white() | |
| fifthStack.addSpacer() | |
| ngsTxt = fifthStack.addText("Num. Of Achievements Won \n" + sumOfAch.toString()) | |
| ngsTxt.font = Font.boldMonospacedSystemFont(12) | |
| ngsTxt.textColor = Color.white() | |
| return widget | |
| } | |
| // Get GamerScore Chart from QuickChart | |
| async function getGamerScoreChart() { | |
| body = { | |
| "version": "2", | |
| "backgroundColor": "transparent", | |
| "width": 500, | |
| "height": 300, | |
| "devicePixelRatio": 2.0, | |
| "format": "png", | |
| "chart": { | |
| "type": "line", | |
| "data": { | |
| "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], | |
| "datasets": [{ | |
| "data": sumofGscByMnth, | |
| "fill": false, | |
| "borderColor": "#fff", | |
| "borderWidth": 5, | |
| "pointRadius": 0, | |
| "lineTension": 0.4 | |
| }] | |
| }, | |
| "options": { | |
| "legend": { | |
| "display": false | |
| }, | |
| "scales": { | |
| "xAxes": [{ | |
| "display": true, | |
| "gridLines": { | |
| "display": false | |
| }, | |
| "ticks": { | |
| "fontColor": "#fff", | |
| "fontStyle": "bold" | |
| } | |
| }], | |
| "yAxes": [{ | |
| "display": true, | |
| "gridLines": { | |
| "display": false | |
| }, | |
| "ticks": { | |
| "fontColor": "#fff", | |
| "fontStyle": "bold" | |
| } | |
| }] | |
| } | |
| } | |
| } | |
| } | |
| let req = new Request(quick_chart_url) | |
| req.method = 'post' | |
| req.headers = { | |
| 'Content-Type': 'application/json' | |
| } | |
| req.body = JSON.stringify(body) | |
| return req.loadImage() | |
| } | |
| // Calculate Sum of GamerScore and Number of Achievements each month | |
| async function getAchievementsByMonth() { | |
| let breakwhile = false | |
| let skip = 0 | |
| while (1) { | |
| achResp = await getUserAchievements(skip) | |
| achievements = achResp.achievements | |
| if (achievements.length == 0) { | |
| break | |
| } | |
| for (let i = 0; i < achievements.length; i++) { | |
| rewards = achievements[i].rewards | |
| const d = new Date(achievements[i].progression.timeUnlocked) | |
| if (d.getFullYear() == getCurrentYear()) { | |
| for (let j = 0; j < rewards.length; j++) { | |
| if (rewards[j].type == 'Gamerscore') { | |
| numOfAchByMnth[d.getMonth()] = numOfAchByMnth[d.getMonth()] + 1 | |
| sumofGscByMnth[d.getMonth()] = sumofGscByMnth[d.getMonth()] + parseInt(rewards[j].value) | |
| sumGamerScore = sumGamerScore + parseInt(rewards[j].value) | |
| sumOfAch++ | |
| } | |
| } | |
| } | |
| } | |
| skip = skip + 1000; | |
| } | |
| } | |
| // Call User Achievements Endpoint | |
| async function getUserAchievements(skip) { | |
| let url = xbox_achievementsurl.replace('<xid>', xbox_id) | |
| url = url + '&maxItems=1000&skipItems=' + skip | |
| let req = new Request(url) | |
| req.headers = { | |
| 'Authorization': xbox_authorization, | |
| 'x-xbl-contract-version': '2', | |
| 'Content-Type': 'application/json' | |
| } | |
| return await req.loadJSON() | |
| } | |
| // GET GAME TITLES PLAYED BY USER | |
| async function getUserTitleHistory() { | |
| let url = xbox_titleHistoryurl.replace('<xid>', xbox_id) | |
| let req = new Request(url) | |
| req.headers = { | |
| 'Authorization': xbox_authorization, | |
| 'x-xbl-contract-version': '2', | |
| 'Content-Type': 'application/json' | |
| } | |
| return await req.loadJSON() | |
| } | |
| // READ XBOX PROFILE INFO | |
| async function getUserProfile() { | |
| let url = xbox_profileurl.replace('<xid>', xbox_id) | |
| let req = new Request(url) | |
| req.headers = { | |
| 'Authorization': xbox_authorization, | |
| 'x-xbl-contract-version': '3', | |
| 'Content-Type': 'application/json' | |
| } | |
| return await req.loadJSON() | |
| } | |
| // GET XSTS TOKEN, USER HASH AND XBOX ID | |
| async function getXSTSAndUHS(xblt) { | |
| let body = { | |
| 'Properties': { | |
| 'SandboxId': 'RETAIL', | |
| 'UserTokens': [xblt] | |
| }, | |
| 'RelyingParty': 'http://xboxlive.com', | |
| 'TokenType': 'JWT' | |
| } | |
| let req = new Request(xbox_live_xstsurl) | |
| req.method = 'post' | |
| req.headers = { | |
| 'Content-Type': 'application/json' | |
| } | |
| req.body = JSON.stringify(body) | |
| return req.loadJSON() | |
| } | |
| // GET XBOX LIVE TOKEN | |
| async function getXBLToken(msat) { | |
| let body = { | |
| 'Properties': { | |
| 'AuthMethod': 'RPS', | |
| 'RpsTicket': 'd=' + msat, | |
| 'SiteName': 'user.auth.xboxlive.com' | |
| }, | |
| 'RelyingParty': 'http://auth.xboxlive.com', | |
| 'TokenType': 'JWT' | |
| } | |
| // console.log(JSON.stringify(body)) | |
| let req = new Request(xbox_live_authurl) | |
| req.method = 'post' | |
| req.headers = { | |
| 'Content-Type': 'application/json' | |
| } | |
| req.body = JSON.stringify(body) | |
| return await req.loadJSON() | |
| } | |
| // GET ACCESS TOKEN FROM MICROSOFT OAUTH2.0 | |
| async function getMSAccessToken() { | |
| let req = new Request(xbox_tokenurl) | |
| req.method = 'POST' | |
| req.headers = { | |
| 'Authorization': xbox_credential_base64, | |
| 'Content-Type': 'application/x-www-form-urlencoded' | |
| } | |
| req.body = 'grant_type=' + encodeURIComponent('refresh_token') + '&refresh_token=' + encodeURIComponent(xbox_refreshtoken) | |
| return await req.loadJSON(); | |
| } | |
| //START AUTHENTICATION AND COLLECT INFO | |
| async function authenticateWithXbox() { | |
| msatr = await getMSAccessToken() | |
| Keychain.set('xbox_refreshtoken', msatr.refresh_token) | |
| xlatr = await getXBLToken(msatr.access_token) | |
| xstsr = await getXSTSAndUHS(xlatr.Token) | |
| xsts = xstsr.Token | |
| uhs = xstsr.DisplayClaims.xui[0].uhs | |
| xbox_id = xstsr.DisplayClaims.xui[0].xid | |
| xbox_authorization = 'XBL3.0 x=' + uhs + ';' + xsts | |
| } | |
| // GET IMAGE FROM URL | |
| async function getImageFromURL(url) { | |
| let req = new Request(url) | |
| return await req.loadImage() | |
| } | |
| //Get Current Year | |
| function getCurrentYear() { | |
| const d = new Date(); | |
| return d.getFullYear(); | |
| } |
Author
Really cool widget, thank you for sharing and the detailed documentation!!
One hint, when you're using Postman Web, the Redirect URI needs to be https://oauth.pstmn.io/v1/browser-callback
Author
@jnnsrctr Good callout for anyone using Postman Web.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screenshot
Instructions
Get Client ID, Client Secret and Refresh Token
App Registrations.New Registration.https://oauth.pstmn.io/v1/callbackas the value. This is important, as we will be using Postman to generate Access andRefresh Token.Application (client) ID.Certificates & secrets, click onNew client secret, choose when you want the secret to expire and click on Add to generate client secret.Client Secretvalue and save it.Refresh tokens. We will use Postman to help with this.OAuth 2.0Authorization Code.Callback URL, check Authorize using browser. This will disable editing the Callback URL and it should be defaulted to the value we set when creating an Application in Azure.Auth URLashttps://login.live.com/oauth20_authorize.srfAccess Token URLashttps://login.live.com/oauth20_token.srfClient IDandClient Secretto the values we obtained from previous section.Xboxlive.signin Xboxlive.offline_accessAccess TokenandRefresh Tokenthat got generated.Refresh Tokensecurely.Save Client ID, Client Secret and Refresh Token to KeyChain
xbox_clientidkey using the below codeKeychain.set('xbox_clientid', '<your client id>')xbox_clientsecretto keychain using the below code.Keychain.set('xbox_clientsecret', '<your client secret>')Keychain.set('xbox_refreshtoken', '<your refresh token>')Run the Script