Files
Bolt/html/index.html
Adam 8de2c7cc12 html: fix typo
Don't ask how long I spent on this, I don't want to talk about it
2023-07-21 01:02:21 +01:00

748 lines
26 KiB
HTML

<!--Main page displayed by the launcher. Basically just a javascript file with a couple of HTML tags around it.-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Bolt Launcher</title>
<style>
body {
overflow: hidden;
z-index: 0;
}
select {
margin-left: 10px;
margin-right: 10px;
margin-top: 10px;
min-width: 110px;
}
hr {
width: 85%;
}
button {
margin-left: 4px;
margin-right: 4px;
margin-top: 8px;
}
.button-red {
background-color:#f49088;
}
.p-disclaimer {
user-select: none;
font-size: 28px;
}
.button-disclaimer {
width: 300px;
height: 70px;
text-align: center;
font-size: 48px;
background-color: #6868E0;
}
.div-bg {
padding-left: 6%;
padding-right: 6%;
padding-top: auto;
padding-bottom: auto;
left: 0;
top: 0;
width: 88%;
height: 100%;
position: absolute;
z-index: 10;
text-align: center;
vertical-align: middle;
background-color: #CCCCCC;
}
</style>
<script>
const internal_url = "https://bolt-internal";
var platform = null;
var credentials = [];
var pendingOauth = null;
var pendingGameAuth = [];
var rs3LinuxInstalledHash = null;
var messages = document.createElement("ul");
var accountSelect = document.createElement("select");
var loginButton = document.createElement("button");
var logoutButton = document.createElement("button");
var loggedInInfo = document.createElement("div");
var loginButtons = document.createElement("div");
var gameAccountSelection = document.createElement("div");
var launchGameButtons = document.createElement("div");
var footer = document.createElement("div");
// checks if `credentials` are about to expire or have already expired,
// and renews them using the oauth endpoint if so
// returns null on success or an http status code on failure
function checkRenewCreds(creds, url, client_id) {
return new Promise((resolve, reject) => {
// only renew if less than 30 seconds left
if (creds.expiry - Date.now() < 30000) {
const post_data = new URLSearchParams({
grant_type: "refresh_token",
client_id: client_id,
refresh_token: creds.refresh_token
});
var xml = new XMLHttpRequest();
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
if (xml.status == 200) {
const c = parseCredentials(xml.response);
if (c) {
Object.assign(creds, c);
resolve(null);
} else {
resolve(0);
}
} else {
resolve(xml.status);
}
}
};
xml.onerror = () => {
resolve(0);
};
xml.open('POST', url, true);
xml.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xml.setRequestHeader("Accept", "application/json");
xml.send(post_data);
} else {
resolve(null);
}
});
}
// parses a response from the oauth endpoint, returning an object to be stored in the `credentials` var
// or returns null on failure
function parseCredentials(str) {
const oauth_creds = JSON.parse(str);
const sections = oauth_creds.id_token.split('.');
if (sections.length !== 3) {
err(`Malformed id_token: ${sections.length} sections, expected 3`, false);
return null;
}
const header = JSON.parse(atob(sections[0]));
if (header.typ !== "JWT") {
err(`Bad id_token header: typ ${header.typ}, expected JWT`, false);
return null;
}
const payload = JSON.parse(atob(sections[1]));
return {
access_token: oauth_creds.access_token,
id_token: oauth_creds.id_token,
refresh_token: oauth_creds.refresh_token,
sub: payload.sub,
login_provider: payload.login_provider || null,
expiry: Date.now() + (oauth_creds.expires_in * 1000)
};
}
// builds the url to be opened in the login window
// async because crypto.subtle.digest is async for some reason, so remember to `await`
async function makeLoginUrl(e) {
const verifier_data = new TextEncoder().encode(e.pkceCodeVerifier);
const digested = await crypto.subtle.digest("SHA-256", verifier_data);
var raw = "";
var bytes = new Uint8Array(digested);
for (var i = 0; i < bytes.byteLength; i++) {
raw += String.fromCharCode(bytes[i]);
}
const code_challenge = btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return e.origin.concat("/oauth2/auth?").concat(new URLSearchParams({
auth_method: e.authMethod,
login_type: e.loginType,
flow: e.flow,
response_type: "code",
client_id: e.clientid,
redirect_uri: e.redirect,
code_challenge: code_challenge,
code_challenge_method: "S256",
prompt: "login",
scope: "openid offline gamesso.token.create user.profile.read",
state: e.pkceState
}));
}
// builds a random PKCE verifier string using crypto.getRandomValues
function makeRandomVerifier() {
var t = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
var n = new Uint32Array(43);
crypto.getRandomValues(n);
return Array.from(n, function(e) {
return t[e % t.length]
}).join("")
}
// builds a random PKCE state string using crypto.getRandomValues
function makeRandomState() {
var t = 0;
var r = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
var n = r.length - 1;
var o = crypto.getRandomValues(new Uint8Array(12));
return Array.from(o).map((e) => {
return Math.round(e * (n - t) / 255 + t)
}).map((e) => { return r[e]; }).join("")
}
// remove an entry by its reference from the pendingGameAuth list
// this function assumes this entry is in the list
function removePendingGameAuth(pending, close_window) {
if (close_window) {
pending.win.close();
}
pendingGameAuth.splice(pendingGameAuth.indexOf(pending));
}
// body's onload function
function start(s) {
const client_id = atob(s.clientid);
const exchange_url = atob(s.origin).concat("/oauth2/token");
const query = new URLSearchParams(window.location.search);
platform = query.get("platform");
rs3LinuxInstalledHash = query.get("rs3_linux_installed_hash");
const c = query.get("credentials");
if (c) {
credentials = JSON.parse(c);
}
window.addEventListener("message", (event) => {
if (event.origin != internal_url && btoa(event.origin).replaceAll('\x3d', '') != s.origin) {
msg(`discarding window message from origin ${event.origin}`);
return;
}
switch (event.data.type) {
case "authCode":
var pending = pendingOauth;
if (pending) {
pendingOauth = null;
const post_data = new URLSearchParams({
grant_type: "authorization_code",
client_id: client_id,
code: event.data.code,
code_verifier: pending.verifier,
redirect_uri: atob(s.redirect),
});
var xml = new XMLHttpRequest();
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
if (xml.status == 200) {
const creds = parseCredentials(xml.response);
if (creds) {
credentials.push(creds);
saveAllCreds();
handleLogin(s, pending.win, creds, exchange_url, client_id);
} else {
err(`Error: invalid credentials received`, false);
pending.win.close();
}
} else {
err(`Error: from ${exchange_url}: ${xml.status}: ${xml.response}`, false);
pending.win.close();
}
}
};
xml.open('POST', exchange_url, true);
xml.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xml.setRequestHeader("Accept", "application/json");
xml.send(post_data);
}
break;
case "initAuth":
msg(`message: init auth: ${event.data.auth_method}`);
break;
case "externalUrl":
msg(`message: external url: ${event.data.url}`);
break;
case "gameSessionServerAuth":
var pending = pendingGameAuth.find((x) => { return event.data.state == x.state });
if (pending) {
removePendingGameAuth(pending, true);
const sections = event.data.id_token.split('.');
if (sections.length !== 3) {
err(`Malformed id_token: ${sections.length} sections, expected 3`, false);
break;
}
const header = JSON.parse(atob(sections[0]));
if (header.typ !== "JWT") {
err(`Bad id_token header: typ ${header.typ}, expected JWT`, false);
break;
}
const payload = JSON.parse(atob(sections[1]));
if (atob(payload.nonce) !== pending.nonce) {
err("Incorrect nonce in id_token", false);
break;
}
const sessions_url = atob(s.auth_api).concat("/sessions");
var xml = new XMLHttpRequest();
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
if (xml.status == 200) {
const accounts_url = atob(s.auth_api).concat("/accounts");
const session_id = JSON.parse(xml.response).sessionId;
var xml2 = new XMLHttpRequest();
xml2.onreadystatechange = () => {
if (xml2.readyState == 4) {
if (xml2.status == 200) {
pending.account_info_promise.then((account_info) => {
if (typeof account_info !== "number") {
const name_text = `${account_info.displayName}#${account_info.suffix}`;
msg(`Successfully added login for ${name_text}`);
var select = document.createElement("select");
JSON.parse(xml2.response).forEach((acc) => {
var opt = document.createElement("option");
opt.value = acc.accountId;
if (acc.displayName) {
opt.name = acc.displayName;
opt.innerText = acc.displayName;
} else {
opt.innerText = `#${acc.accountId}`;
}
select.appendChild(opt);
});
const gen_login_vars = (f, element, opt) => {
const acc_id = opt.value;
const acc_name = opt.name;
f(s, element, null, null, session_id, acc_id, acc_name);
};
addNewAccount(name_text, gen_login_vars, select);
} else {
err(`Error getting account info: ${account_info}`, false);
}
});
} else {
err(`Error: from ${accounts_url}: ${xml2.status}: ${xml2.response}`, false);
}
}
};
xml2.open('GET', accounts_url, true);
xml2.setRequestHeader("Accept", "application/json");
xml2.setRequestHeader("Authorization", "Bearer ".concat(session_id));
xml2.send();
} else {
err(`Error: from ${sessions_url}: ${xml.status}: ${xml.response}`, false);
}
}
};
xml.open('POST', sessions_url, true);
xml.setRequestHeader("Content-Type", "application/json");
xml.setRequestHeader("Accept", "application/json");
xml.send(`{"idToken": "${event.data.id_token}"}`);
}
break;
default:
msg("Unknown message type: ".concat(event.data.type));
break;
}
});
var accounts_label = document.createElement("label");
accounts_label.innerText = "Logged in as:";
accounts_label.for = accountSelect;
loggedInInfo.appendChild(accounts_label);
loggedInInfo.appendChild(accountSelect);
accountSelect.onchange = () => {
clearElement(launchGameButtons);
clearElement(gameAccountSelection);
if (accountSelect.selectedIndex >= 0) {
const opt = accountSelect.options[accountSelect.selectedIndex];
generateAccountSelection(opt.genLoginVars, opt.gameAccountSelect);
}
};
loginButton.innerText = "Log in";
loginButton.onclick = () => {
if (pendingOauth === null || pendingOauth.win.closed) {
var state = makeRandomState();
const verifier = makeRandomVerifier();
makeLoginUrl({
origin: atob(s.origin),
redirect: atob(s.redirect),
authMethod: "",
loginType: "",
clientid: client_id,
flow: "launcher",
pkceState: state,
pkceCodeVerifier: verifier,
}).then((e) => {
var win = window.open(e, "", "width=480,height=720");
pendingOauth = { state: state, verifier: verifier, win: win };
});
} else {
pendingOauth.win.focus();
}
};
logoutButton.innerText = "Log out of all accounts";
logoutButton.setAttribute("class", "button-red");
logoutButton.onclick = () => {
msg("Note: the logout button doesn't do anything yet");
};
var p = document.createElement("p");
p.innerText = "Messages:";
loginButtons.appendChild(loginButton);
loginButtons.appendChild(logoutButton);
document.body.appendChild(loginButtons);
document.body.appendChild(loggedInInfo);
document.body.appendChild(document.createElement("br"));
document.body.appendChild(document.createElement("hr"));
document.body.appendChild(gameAccountSelection);
document.body.appendChild(launchGameButtons);
document.body.appendChild(document.createElement("br"));
document.body.appendChild(document.createElement("hr"));
document.body.appendChild(p);
document.body.appendChild(messages);
var loading = document.createElement("div");
loading.setAttribute("class", "div-bg");
document.body.appendChild(loading);
if (!c) {
var disclaimer = document.createElement("div");
var close_disclaimer = document.createElement("button");
disclaimer.setAttribute("class", "div-bg");
close_disclaimer.setAttribute("class", "button-disclaimer");
close_disclaimer.innerText = "I understand";
close_disclaimer.onclick = () => {
disclaimer.remove();
};
var p1 = document.createElement("p");
p1.setAttribute("class", "p-disclaimer");
p1.innerHTML = atob("Qm9sdCBpcyBhbiA8Yj51bm9mZmljaWFsIHRoaXJkLXBhcnR5IGxhdW5jaGVyPC9iPi4gSXQncyBmcmVlIGFuZCBvcGVuLXNvdXJjZSBzb2Z0d2FyZSBsaWNlbnNlZCB1bmRlciBBR1BMIDMuMC4=");
var p2 = document.createElement("p");
p2.setAttribute("class", "p-disclaimer");
p2.innerHTML = atob("SmFnZXggaXMgPGI+bm90IHJlc3BvbnNpYmxlPC9iPiBmb3IgYW55IHByb2JsZW1zIG9yIGRhbWFnZSBjYXVzZWQgYnkgdXNpbmcgdGhpcyBwcm9kdWN0Lg");
disclaimer.appendChild(document.createElement("br"));
disclaimer.appendChild(p1);
disclaimer.appendChild(p2);
disclaimer.appendChild(close_disclaimer);
document.body.appendChild(disclaimer);
}
(async () => {
const promises = credentials.map((x) => { checkRenewCreds(x, exchange_url, client_id); });
credentials = (await Promise.all(promises)).filter((x) => {
if (x === null) {
handleLogin(s, null, x, exchange_url, client_id);
}
return x === null || x === 0;
});
saveAllCreds();
loading.remove();
})();
}
// called on new successful login with credentials, delegates to a specific handler based on login_provider value
function handleLogin(s, win, creds, exchange_url, client_id) {
switch (creds.login_provider) {
case atob(s.provider):
if (win) {
win.close();
}
handleGameLogin(s, creds, exchange_url, client_id);
break;
default:
handleStandardLogin(s, win, creds, exchange_url, client_id);
break;
}
}
// called when login was successful, but with absent or unrecognised login_provider
function handleStandardLogin(s, w, creds, refresh_url, client_id) {
const state = makeRandomState();
const nonce = crypto.randomUUID();
const location = atob(s.origin).concat("/oauth2/auth?").concat(new URLSearchParams({
id_token_hint: creds.id_token,
nonce: btoa(nonce),
prompt: "consent",
redirect_uri: "http://localhost",
response_type: "id_token code",
state: state,
client_id: "1fddee4e-b100-4f4e-b2b0-097f9088f9d2",
scope: "openid offline"
}));
var win = w;
if (win) {
win.location.href = location;
} else {
win = window.open(location, "", "width=480,height=720");
}
pendingGameAuth.push({
state: state,
nonce: nonce,
win: win,
account_info_promise: new Promise((resolve, reject) => {
const url = atob(s.api).concat("/users/").concat(creds.sub).concat("/displayName");
var xml = new XMLHttpRequest();
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
if (xml.status == 200) {
resolve(JSON.parse(xml.response));
} else {
resolve(xml.status);
}
}
};
xml.open('GET', url, true);
xml.setRequestHeader("Authorization", "Bearer ".concat(creds.access_token));
xml.send();
})
});
}
// called after successful login using a game account as the login_provider
function handleGameLogin(s, creds, refresh_url, client_id) {
const auth_url = atob(s.profile_api).concat("/profile");
var xml = new XMLHttpRequest();
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
if (xml.status == 200) {
// note: select element is just for show here and unused in the callback,
// since there can only be one game account in this situation
const name_info = JSON.parse(xml.response);
const name_text = name_info.displayNameSet ? name_info.displayName : `#${creds.sub}`;
var select = document.createElement("select");
var opt = document.createElement("option");
opt.innerText = name_text;
select.add(opt);
msg(`Successfully added login for ${name_text}`);
addNewAccount(name_text, (f, element, opt) => {
getShieldTokens(creds, atob(s.shield_url), name_info.displayNameSet ? name_info.displayName : "", refresh_url, client_id).then((e) => {
if (typeof e !== "number") {
f(s, element, e.access_token, e.refresh_token, null, null, name_info.displayNameSet ? name_info.displayName : null);
} else {
err(`Error getting shield tokens: ${atob(s.shield_url)}: ${e}`, false);
element.disabled = false;
}
});
}, select);
} else {
err(`Error: from ${auth_url}: ${xml.status}: ${xml.response}`, false);
}
}
};
xml.onerror = () => {
err(`Error: ${auth_url}: non-http error`, false);
};
xml.open('GET', auth_url, true);
xml.setRequestHeader("Authorization", "Bearer ".concat(creds.id_token));
xml.send();
}
// use oauth creds to get a response from the "shield" endpoint
// returns a JSON object on success or a HTTP status code on failure
function getShieldTokens(creds, url, display_name, refresh_url, client_id) {
return new Promise((resolve, reject) => {
checkRenewCreds(creds, refresh_url, client_id).then((status) => {
if (status === null) {
saveAllCreds();
var xml = new XMLHttpRequest();
xml.open('POST', url, true);
xml.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xml.setRequestHeader("Authorization", "Basic Y29tX2phZ2V4X2F1dGhfZGVza3RvcF9yczpwdWJsaWM=");
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
if (xml.status == 200) {
resolve(JSON.parse(xml.response));
} else {
resolve(xml.status);
}
}
};
xml.send(new URLSearchParams({
token: creds.access_token,
grant_type: "token_exchange",
scope: "gamesso.token.create"
}));
} else {
err(`Error renewing credentials: ${refresh_url}: ${status}`, false);
resolve(status);
}
});
});
}
// sends a request to save all credentials to their config file,
// overwriting the previous file, if any
async function saveAllCreds() {
var xml = new XMLHttpRequest();
xml.open('POST', "/save-credentials", true);
xml.setRequestHeader("Content-Type", "application/json");
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
msg(`Save-credentials status: ${xml.responseText.trim()}`);
}
};
xml.send(JSON.stringify(credentials));
}
// adds an option to the accounts drop-down menu at the top of the page
// genLoginVars is the function that will be passed to generateLaunchButtons
function addNewAccount(name, genLoginVars, select) {
const index = accountSelect.childElementCount;
var opt = document.createElement("option");
opt.genLoginVars = genLoginVars;
opt.name = name;
opt.innerText = name;
opt.gameAccountSelect = select;
accountSelect.add(opt);
accountSelect.selectedIndex = index;
accountSelect.onchange();
}
// populates the gameAccountSelection element and by extension the launchGameButtons element
// "f" is the function passed to generateLaunchButtons, "select" is a HTML Select element
function generateAccountSelection(f, select) {
clearElement(gameAccountSelection);
var label = document.createElement("label");
label.for = select;
label.innerText = "Choose a game account:";
select.onchange = () => {
clearElement(launchGameButtons);
if (select.selectedIndex >= 0) {
generateLaunchButtons(f, select.options[select.selectedIndex]);
}
};
select.onchange();
gameAccountSelection.appendChild(label);
gameAccountSelection.appendChild(select);
}
// populates the launchGameButtons element with buttons
// the parameter is a function callback, which must take exactly two arguments:
// 1. a function which your function should invoke with JX env variables, e.g. launchRS3Linux
// 2. a HTML Button element, which should be passed to parameter 1 when invoking it, or, if not
// 3. a HTML Option element, representing the currently selected game account
// invoking the callback for some reason, then set disabled=false on parameter 2 before returning
function generateLaunchButtons(f, opt) {
if (platform === "linux") {
var rs3_linux = document.createElement("button");
rs3_linux.onclick = () => { rs3_linux.disabled = true; f(launchRS3Linux, rs3_linux, opt); };
rs3_linux.innerText = "Launch RS3";
launchGameButtons.appendChild(rs3_linux);
}
}
// asynchronously download and launch RS3's official .deb client using the given env variables
function launchRS3Linux(s, element, jx_access_token, jx_refresh_token, jx_session_id, jx_character_id, jx_display_name) {
const launch = (hash, deb) => {
var xml = new XMLHttpRequest();
var params = {};
if (hash) params.hash = hash;
if (jx_access_token) params.jx_access_token = jx_access_token;
if (jx_refresh_token) params.jx_refresh_token = jx_refresh_token;
if (jx_session_id) params.jx_session_id = jx_session_id;
if (jx_character_id) params.jx_character_id = jx_character_id;
if (jx_display_name) params.jx_display_name = jx_display_name;
xml.open('POST', "/launch-deb?".concat(new URLSearchParams(params)), true);
xml.onreadystatechange = () => {
if (xml.readyState == 4) {
msg(`Game launch status: '${xml.responseText.trim()}'`);
if (xml.status == 200 && hash) {
rs3LinuxInstalledHash = hash;
}
element.disabled = false;
}
};
xml.onerror = () => {
element.disabled = false;
};
xml.send(deb);
};
var xml = new XMLHttpRequest();
const content_url = atob(s.content_url);
const url = content_url.concat("dists/trusty/non-free/binary-amd64/Packages");
xml.open('GET', url, true);
xml.onreadystatechange = () => {
if (xml.readyState == 4 && xml.status == 200) {
const lines = Object.fromEntries(xml.response.split('\n').map((x) => { return x.split(": "); }));
if (!lines.Filename || !lines.Size) {
err(`Could not parse package data from URL: ${url}`, false);
launch();
return;
}
if (lines.SHA256 !== rs3LinuxInstalledHash) {
var m = msg("Downloading game client... 0%");
var exe_xml = new XMLHttpRequest();
exe_xml.open('GET', content_url.concat(lines.Filename), true);
exe_xml.responseType = "arraybuffer";
exe_xml.onprogress = (e) => {
if (e.loaded) {
m.innerText = `Downloading game client... ${(Math.round(1000.0 * e.loaded / lines.Size) / 10.0).toFixed(1)}%`;
}
};
exe_xml.onreadystatechange = () => {
if (exe_xml.readyState == 4 && exe_xml.status == 200) {
launch(lines.SHA256, exe_xml.response);
}
};
exe_xml.onerror = () => {
err(`Error downloading game client: from ${url}: non-http error`, false);
launch();
};
exe_xml.send();
} else {
msg("Latest client is already installed");
launch();
}
}
};
xml.onerror = () => {
err(`Error: from ${url}: non-http error`, false);
launch();
};
xml.send();
}
// clears all child content from an element
function clearElement(e) {
while (e.lastElementChild) {
e.removeChild(e.lastElementChild);
}
}
// adds a message to the message list, returning the inner <p> element
function msg(str) {
console.log(str);
var p = document.createElement("p");
p.innerText = str;
var li = document.createElement("li");
li.appendChild(p);
messages.insertBefore(li, messages.firstChild);
while (messages.childElementCount > 20) {
messages.removeChild(messages.lastChild);
}
return p;
}
// adds an error message to the message list
// if do_throw is true, throws the error message
function err(str, do_throw) {
if (!do_throw) {
console.error(str);
}
while (messages.childElementCount > 20) {
messages.removeChild(messages.lastChild);
}
if (do_throw) {
throw new Error(str);
}
}
</script>
</head>
<body onload="start(s())" />
</html>