mirror of
https://github.com/Adamcake/Bolt.git
synced 2026-04-24 02:46:56 -04:00
570 lines
20 KiB
HTML
570 lines
20 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>
|
|
<script>
|
|
const internal_url = "https://bolt-internal";
|
|
var platform = null;
|
|
var root = null;
|
|
var messages = document.createElement("ul");
|
|
var credentials = null;
|
|
var pendingGameAuth = null;
|
|
var rs3LinuxInstalledHash = null;
|
|
|
|
// 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(url, client_id) {
|
|
return new Promise((resolve, reject) => {
|
|
// only renew if less than 30 seconds left
|
|
if (credentials.expiry - Date.now() < 30000) {
|
|
const post_data = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: client_id,
|
|
refresh_token: credentials.refresh_token
|
|
});
|
|
var xml = new XMLHttpRequest();
|
|
xml.onreadystatechange = () => {
|
|
if (xml.readyState == 4) {
|
|
if (xml.status == 200) {
|
|
parseCredentials(xml.response);
|
|
resolve(null);
|
|
} 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 into the `credentials` var
|
|
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`, true);
|
|
}
|
|
const header = JSON.parse(atob(sections[0]));
|
|
if (header.typ !== "JWT") {
|
|
err(`Bad id_token header: typ ${header.typ}, expected JWT`, true);
|
|
}
|
|
const payload = JSON.parse(atob(sections[1]));
|
|
credentials = {
|
|
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 make_random_verifier() {
|
|
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 make_random_state() {
|
|
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("")
|
|
}
|
|
|
|
// body's onload function
|
|
function start(s) {
|
|
root = document.body;
|
|
root.appendChild(footer(false));
|
|
const state = make_random_state();
|
|
const verifier = make_random_verifier();
|
|
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);
|
|
}
|
|
|
|
var w = window.open("about:blank", "", "width=480,height=720");
|
|
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":
|
|
if (event.data.state == state) {
|
|
const post_data = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: client_id,
|
|
code: event.data.code,
|
|
code_verifier: verifier,
|
|
redirect_uri: atob(s.redirect)
|
|
});
|
|
var xml = new XMLHttpRequest();
|
|
xml.onreadystatechange = () => {
|
|
if (xml.readyState == 4) {
|
|
if (xml.status == 200) {
|
|
parseCredentials(xml.response);
|
|
handleLogin(s, w, exchange_url, client_id, state);
|
|
} else {
|
|
w.close();
|
|
err(`Error: from ${exchange_url}: ${xml.status}: ${xml.response}`, false);
|
|
}
|
|
}
|
|
};
|
|
xml.onerror = () => {
|
|
w.close();
|
|
err(`Error: from ${exchange_url}: non-http error`, false);
|
|
};
|
|
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":
|
|
if (pendingGameAuth && pendingGameAuth.state == event.data.state) {
|
|
w.close();
|
|
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) !== pendingGameAuth.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) {
|
|
pendingGameAuth.account_info_promise.then((account_info) => {
|
|
pendingGameAuth = null;
|
|
if (typeof account_info !== "number") {
|
|
clearRoot();
|
|
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);
|
|
});
|
|
var logged_in = document.createElement("p");
|
|
logged_in.innerHTML = `Currently logged in as <b>${account_info.displayName}</b>#${account_info.suffix}`;
|
|
root.appendChild(logged_in);
|
|
var accounts = document.createElement("p");
|
|
var accounts_label = document.createElement("label");
|
|
accounts_label.for = select;
|
|
accounts_label.innerText = "Account: ";
|
|
accounts.appendChild(accounts_label);
|
|
accounts.appendChild(select);
|
|
root.appendChild(accounts);
|
|
root.appendChild(generateLaunchButtons((f, element) => {
|
|
const acc_id = select.value;
|
|
const acc_name = select.options[select.selectedIndex].getAttribute("name");
|
|
f(s, element, null, null, session_id, acc_id, acc_name);
|
|
}));
|
|
root.appendChild(footer(true));
|
|
} else {
|
|
err(`Error getting account info: ${account_info}`, false);
|
|
}
|
|
});
|
|
} else {
|
|
err(`Error: from ${accounts_url}: ${xml2.status}: ${xml2.response}`, false);
|
|
}
|
|
}
|
|
};
|
|
xml2.onerror = () => {
|
|
err(`Error: from ${accounts_url}: non-http error`, 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.onerror = () => {
|
|
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;
|
|
}
|
|
});
|
|
|
|
if (credentials) {
|
|
handleLogin(s, w, exchange_url, client_id, state);
|
|
} else {
|
|
makeLoginUrl({
|
|
origin: atob(s.origin),
|
|
redirect: atob(s.redirect),
|
|
authMethod: "",
|
|
loginType: "",
|
|
clientid: client_id,
|
|
flow: "launcher",
|
|
pkceState: state,
|
|
pkceCodeVerifier: verifier,
|
|
}).then((e) => { w.location.replace(e); });
|
|
}
|
|
}
|
|
|
|
// called on new successful login with credentials, delegates to a specific handler based on login_provider value
|
|
function handleLogin(s, w, exchange_url, client_id, state) {
|
|
switch (credentials.login_provider) {
|
|
case atob(s.provider):
|
|
w.close();
|
|
handleGameLogin(s, exchange_url, client_id);
|
|
break;
|
|
default:
|
|
handleStandardLogin(s, w, exchange_url, client_id, state);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// called when login was successful, but with absent or unrecognised login_provider
|
|
function handleStandardLogin(s, w, refresh_url, client_id, state) {
|
|
if (pendingGameAuth !== null) {
|
|
throw new Error("game auth flow started while another was already pending");
|
|
}
|
|
const nonce = crypto.randomUUID();
|
|
pendingGameAuth = {
|
|
state: state,
|
|
nonce: nonce,
|
|
account_info_promise: new Promise((resolve, reject) => {
|
|
const url = atob(s.api).concat("/users/").concat(credentials.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.onerror = () => {
|
|
resolve(0);
|
|
};
|
|
xml.open('GET', url, true);
|
|
xml.setRequestHeader("Authorization", "Bearer ".concat(credentials.access_token));
|
|
xml.send();
|
|
})
|
|
};
|
|
w.location.href = atob(s.origin).concat("/oauth2/auth?").concat(new URLSearchParams({
|
|
id_token_hint: credentials.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"
|
|
}));
|
|
}
|
|
|
|
// called after successful login using a game account as the login_provider
|
|
function handleGameLogin(s, 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) {
|
|
const name_info = JSON.parse(xml.response);
|
|
clearRoot();
|
|
var logged_in = document.createElement("p");
|
|
if (name_info.displayNameSet) {
|
|
logged_in.innerHTML = `Currently logged in as <b>${name_info.displayName}</b>`;
|
|
} else {
|
|
logged_in.innerText = "Currently logged in (no display name set)";
|
|
}
|
|
root.appendChild(logged_in);
|
|
root.appendChild(generateLaunchButtons((f, element) => {
|
|
getShieldTokens(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;
|
|
}
|
|
});
|
|
}));
|
|
root.appendChild(footer(true));
|
|
} 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(credentials.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(url, display_name, refresh_url, client_id) {
|
|
return new Promise((resolve, reject) => {
|
|
checkRenewCreds(refresh_url, client_id).then((status) => {
|
|
if (status === null) {
|
|
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.onerror = () => {
|
|
resolve(xml.status);
|
|
};
|
|
xml.send(new URLSearchParams({
|
|
token: credentials.access_token,
|
|
grant_type: "token_exchange",
|
|
scope: "gamesso.token.create"
|
|
}));
|
|
} else {
|
|
err(`Error renewing credentials: ${refresh_url}: ${status}`, false);
|
|
resolve(status);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// generates an HTML element containing launch-game 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
|
|
// invoking the callback for some reason, then set disabled=false on parameter 2 before returning
|
|
function generateLaunchButtons(f) {
|
|
var div = document.createElement("div");
|
|
|
|
if (platform === "linux") {
|
|
var rs3_linux = document.createElement("button");
|
|
rs3_linux.onclick = () => { rs3_linux.disabled = true; f(launchRS3Linux, rs3_linux); };
|
|
rs3_linux.innerText = "Launch RS3";
|
|
div.appendChild(rs3_linux);
|
|
}
|
|
|
|
return div;
|
|
}
|
|
|
|
// 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 content from root
|
|
function clearRoot() {
|
|
while (root.lastElementChild) {
|
|
root.removeChild(root.lastElementChild);
|
|
}
|
|
}
|
|
|
|
// generates footer element for pages
|
|
function footer(separator) {
|
|
if (separator) {
|
|
root.appendChild(document.createElement("br"));
|
|
var hr = document.createElement("hr");
|
|
hr.style = "width: 85%;";
|
|
root.appendChild(hr);
|
|
}
|
|
var d = document.createElement("div");
|
|
var p = document.createElement("p");
|
|
p.innerText = "Output:";
|
|
d.appendChild(p);
|
|
d.appendChild(messages);
|
|
return d;
|
|
}
|
|
|
|
// 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);
|
|
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);
|
|
}
|
|
var p = document.createElement("p");
|
|
p.innerText = str;
|
|
p.style = "background-color: #FF30303A;";
|
|
var li = document.createElement("li");
|
|
li.appendChild(p);
|
|
messages.insertBefore(li, messages.firstChild);
|
|
if (do_throw) {
|
|
throw new Error(str);
|
|
}
|
|
}
|
|
</script>
|
|
</head>
|
|
<body style="overflow:hidden" onload="start(s())" />
|
|
</html>
|