Show a status icon if a virus is being made or not
// ==UserScript==
// @name Torn.com Virus Programming Indicator
// @namespace dev.wiing.virusindicator
// @version 0.2.0
// @description Show a status icon if a virus is being made or not
// @author BLOODWIING[3891894]
// @license MIT
// @match https://www.torn.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=tornexchange.com
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// ==/UserScript==
(function() {
'use strict';
const plugin_name = 'VirusIndicator';
const log = (x) => { console.log(`[${plugin_name}] ${x}`) };
const error = (x) => { console.error(`[${plugin_name}] ${x}`) };
const virusCodeSvgIcon = `<?xml version='1.0' encoding='UTF-8'?><svg width='34' height='16' fill='none' version='1.1' viewBox='0 0 34 16' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><path d='m2 4v4h5v-1h-4v-3zm11 0v3h-4v1h5v-4zm-12 5v1h6v-1zm8 0v1h6v-1zm-7 2v4h1v-3h4.23c-0.146-0.327-0.23-0.663-0.23-1zm7 0c0 0.337-0.0844 0.673-0.23 1h4.23v3h1v-4z' fill='url(%23linearGradient1271)' stroke='%23357534' stroke-linecap='square' stroke-width='2' style='paint-order:stroke markers fill'/><defs><linearGradient id='linearGradient1266' x1='548' x2='548' y1='19' y2='24' gradientTransform='matrix(.667 0 0 .667 -357 -10.3)' gradientUnits='userSpaceOnUse'><stop stop-color='%2335824c' offset='0'/><stop stop-color='%23c3ea5b' offset='1'/></linearGradient><linearGradient id='linearGradient1271' x1='548' x2='548' y1='34' y2='21' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23d7e755' offset='0'/><stop stop-color='%2359a94d' offset='1'/></linearGradient><linearGradient id='linearGradient1276' x1='548' x2='548' y1='24' y2='33' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23d0ff4d' offset='0'/><stop stop-color='%2379a266' offset='1'/></linearGradient><linearGradient id='linearGradient1278' x1='548' x2='548' y1='26' y2='30' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23151515' offset='0'/><stop stop-color='%236c9d51' offset='1'/></linearGradient><linearGradient id='linearGradient1280' x1='550' x2='550' y1='24' y2='33' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%2362a351' offset='0'/><stop stop-color='%2362a351' stop-opacity='0' offset='1'/></linearGradient><linearGradient id='linearGradient1285' x1='548' x2='548' y1='26' y2='30' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23585858' offset='0'/><stop stop-color='%23919191' offset='1'/></linearGradient><linearGradient id='linearGradient1286' x1='550' x2='550' y1='24' y2='33' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%237a7a7a' offset='0'/><stop stop-color='%237a7a7a' stop-opacity='0' offset='1'/></linearGradient><linearGradient id='linearGradient1287' x1='548' x2='548' y1='24' y2='33' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23d6d6d6' offset='0'/><stop stop-color='%23848484' offset='1'/></linearGradient><linearGradient id='linearGradient1288' x1='548' x2='548' y1='19' y2='24' gradientTransform='matrix(.667 0 0 .667 -339 -10.3)' gradientUnits='userSpaceOnUse'><stop stop-color='%23797979' offset='0'/><stop stop-color='%23c5c5c5' offset='1'/></linearGradient><linearGradient id='linearGradient1289' x1='548' x2='548' y1='34' y2='21' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23c5c5c5' offset='0'/><stop stop-color='%237b7b7b' offset='1'/></linearGradient><linearGradient id='linearGradient1291' x1='546' x2='555' y1='32' y2='32' gradientTransform='translate(-539,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23c28f20' offset='0'/><stop stop-color='%23fdff8e' offset='1'/></linearGradient><linearGradient id='linearGradient1304' x1='572' x2='572' y1='25' y2='30' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23eed87d' offset='0'/><stop stop-color='%23cd8949' offset='1'/></linearGradient><linearGradient id='linearGradient1306' x1='572' x2='572' y1='31' y2='33' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23e4cc66' offset='0'/><stop stop-color='%23d18552' offset='1'/></linearGradient></defs><g stroke='%233d893d' stroke-linecap='square' stroke-width='2'><path d='m5 2-0.709 0.709 1.39 1.39c0.213-0.261 0.469-0.486 0.758-0.662zm6 0-1.44 1.44c0.289 0.176 0.544 0.402 0.758 0.662l1.39-1.39z' fill='%23b3d6ab' style='paint-order:stroke markers fill'/><path d='m8 3c-0.382 0-0.74 0.106-1.04 0.29-0.193 0.117-0.363 0.268-0.505 0.441-0.282 0.345-0.452 0.786-0.452 1.27h4c0-0.482-0.169-0.923-0.452-1.27-0.142-0.174-0.312-0.324-0.505-0.441-0.303-0.185-0.661-0.29-1.04-0.29z' fill='url(%23linearGradient1266)' style='paint-order:stroke markers fill'/><path d='m6 6c-0.554 0-1 0.446-1 1v4c0 0.337 0.0844 0.673 0.23 1 0.722 1.62 1.94 3 2.77 3s2.05-1.38 2.77-3c0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1276)' style='paint-order:stroke markers fill'/></g><g><path d='m8 6v9c0.832 0 2.05-1.38 2.77-3 0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1280)' opacity='.655' style='paint-order:stroke markers fill'/><path d='m8 8a2 2 0 0 0-2 2 2 2 0 0 0 1 1.73v0.268h2v-0.268a2 2 0 0 0 1-1.73 2 2 0 0 0-2-2zm-0.666 2.33a0.333 0.333 0 0 1 0.332 0.332 0.333 0.333 0 0 1-0.332 0.334 0.333 0.333 0 0 1-0.334-0.334 0.333 0.333 0 0 1 0.334-0.332zm1.33 0a0.333 0.333 0 0 1 0.334 0.332 0.333 0.333 0 0 1-0.334 0.334 0.333 0.333 0 0 1-0.332-0.334 0.333 0.333 0 0 1 0.332-0.332z' fill='url(%23linearGradient1278)' style='paint-order:stroke markers fill'/><g stroke-linecap='square' stroke-width='2'><path d='m20 4v4h5v-1h-4v-3zm11 0v3h-4v1h5v-4zm-12 5v1h6v-1zm8 0v1h6v-1zm-7 2v4h1v-3h4.23c-0.146-0.327-0.23-0.663-0.23-1zm7 0c0 0.337-0.0844 0.673-0.23 1h4.23v3h1v-4z' fill='url(%23linearGradient1289)' stroke='%23696969' style='paint-order:stroke markers fill'/><path d='m23 2-0.709 0.709 1.39 1.39c0.213-0.261 0.469-0.486 0.758-0.662zm6 0-1.44 1.44c0.289 0.176 0.544 0.402 0.758 0.662l1.39-1.39z' fill='%23d2d2d2' stroke='%237a7a7a' style='paint-order:stroke markers fill'/><path d='m26 3c-0.382 0-0.74 0.106-1.04 0.29-0.193 0.117-0.363 0.268-0.505 0.441-0.282 0.345-0.452 0.786-0.452 1.27h4c0-0.482-0.169-0.923-0.452-1.27-0.142-0.174-0.312-0.324-0.505-0.441-0.303-0.185-0.661-0.29-1.04-0.29z' fill='url(%23linearGradient1288)' stroke='%237a7a7a' style='paint-order:stroke markers fill'/></g></g><path d='m24 6c-0.554 0-1 0.446-1 1v4c0 0.337 0.0844 0.673 0.23 1 0.722 1.62 1.94 3 2.77 3s2.05-1.38 2.77-3c0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1287)' stroke='%237a7a7a' stroke-linecap='square' stroke-width='2' style='paint-order:stroke markers fill'/><path d='m26 6v9c0.832 0 2.05-1.38 2.77-3 0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1286)' opacity='.655' style='paint-order:stroke markers fill'/><path d='m26 8a2 2 0 0 0-2 2 2 2 0 0 0 1 1.73v0.268h2v-0.268a2 2 0 0 0 1-1.73 2 2 0 0 0-2-2zm-0.666 2.33a0.333 0.333 0 0 1 0.332 0.332 0.333 0.333 0 0 1-0.332 0.334 0.333 0.333 0 0 1-0.334-0.334 0.333 0.333 0 0 1 0.334-0.332zm1.33 0a0.333 0.333 0 0 1 0.334 0.332 0.333 0.333 0 0 1-0.334 0.334 0.333 0.333 0 0 1-0.332-0.334 0.333 0.333 0 0 1 0.332-0.332z' fill='url(%23linearGradient1285)' style='paint-order:stroke markers fill'/><g stroke-linecap='square' stroke-width='2'><path d='m9 13a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm2.5 0a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm2.5 0a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1z' fill='url(%23linearGradient1291)' stroke='%23946c26' style='paint-order:stroke markers fill'/><rect x='31' y='7' width='2' height='5' fill='url(%23linearGradient1304)' stroke='%23aa5049' style='paint-order:stroke markers fill'/><circle cx='32' cy='14' r='1' fill='url(%23linearGradient1306)' stroke='%23aa5049' style='paint-order:stroke markers fill'/></g></svg>`;
// BELOW API KEY CODE IS INSPIRED BY rDacted's FF SCOUTER v2
// https://greasyfork.org/en/scripts/535292-ff-scouter-v2
var apikey = "###PDA-APIKEY###";
var VI_setValue;
var VI_getValue;
var VI_deleteValue;
var VI_registerMenuCommand;
if (apikey[0] != "#") {
VI_setValue = function (name, value) {
log(`Setting value for ${name}`);
return localStorage.setItem(name, value);
};
VI_getValue = function (name, defaultValue) {
var value = localStorage.getItem(name) ?? defaultValue;
return value;
};
VI_deleteValue = function (name) {
log(`Deleting value for ${name}`);
return localStorage.removeItem(name);
};
VI_registerMenuCommand = function () {
log(`Disabling GM_registerMenuCommand`);
};
VI_setValue("minimal_key", apikey);
} else {
VI_setValue = GM_setValue;
VI_getValue = GM_getValue;
VI_deleteValue = GM_deleteValue;
VI_registerMenuCommand = GM_registerMenuCommand;
}
VI_registerMenuCommand("Enter minimal API Key", () => {
let userInput = prompt(
`[${plugin_name}]: Enter minimal API Key`,
VI_getValue("minimal_key", ""),
);
if (userInput !== null) {
VI_setValue("minimal_key", userInput);
// Reload page
window.location.reload();
}
});
VI_registerMenuCommand("Clear virus cache", () => {
clearVirusCache();
window.location.reload();
});
// END BORROWED CODE
var API_COMMENT = plugin_name;
var API_KEY = VI_getValue("minimal_key", null);
// --- data ----------------------------------------------------------
const CACHE_KEY = "virus_cache";
const CACHE_TTL_MS = 3600 * 1000; // 1 hour
/*
null | {item: {id: number, name: string}, until: number}
*/
let virus_data = null;
function readVirusCache() {
log('Reading virus data from cache');
const raw = VI_getValue(CACHE_KEY, null);
if (!raw) return null;
try {
const json = JSON.parse(raw);
log(`Virus data cache lasts for ${formatRelativeTime(untilTimestampToRelativeTime(Math.floor(json.expires / 1000)))}`)
return json;
} catch (e) {
return null;
}
}
function writeVirusCache(data, { ttl = null } = {}) {
const true_ttl = ttl ? Math.min(CACHE_TTL_MS, ttl) : CACHE_TTL_MS;
log(`Saving virus data to cache for ${true_ttl} (${ttl}) TTL`);
VI_setValue(CACHE_KEY, JSON.stringify({
data: data,
expires: Date.now() + true_ttl,
}));
}
function clearVirusCache() {
VI_deleteValue(CACHE_KEY);
virus_data = null;
log('Virus cache cleared');
}
// --- static helpers ----------------------------------------------------------
function onElement(selector, callback, { once = true, root = document.body, attributes = false } = {}) {
const run = () => {
const el = document.querySelector(selector);
if (el) {
callback(el);
return true;
}
return false;
};
if (run() && once) return;
const obs = new MutationObserver(() => {
if (run() && once) obs.disconnect();
});
obs.observe(root, { childList: true, subtree: true, attributes: attributes });
return obs;
}
function onAttribute(element, attribute, callback, { once = true } = {}) {
const run = () => {
const val = element.getAttribute(attribute);
if (val !== null) {
callback(val);
return true;
}
return false;
};
if (run() && once) return;
const obs = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "attributes") {
if (run() && once) obs.disconnect();
}
});
});
obs.observe(element, { attributes: true });
return obs;
}
// --- utils ----------------------------------------------------------
function httpGetJSON(url) {
if (typeof GM_xmlhttpRequest === "function") {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 15000,
onload: (res) => resolve({ status: res.status, text: res.responseText }),
onerror: () => reject(new Error("Network error")),
ontimeout: () => reject(new Error("Request timed out")),
});
});
}
if (typeof PDA_httpGet === "function") {
return Promise.resolve(PDA_httpGet(url)).then((res) => ({
status: res.status ?? res.responseCode ?? 200,
text: res.responseText ?? res.body ?? res.response ?? "",
}));
}
return fetch(url).then((res) => res.text().then((text) => ({ status: res.status, text })));
}
function untilTimestampToRelativeTime(timestamp) {
const now = Math.floor(Date.now() / 1000);
var remaining = timestamp - now;
const days = Math.floor(remaining / 86400);
remaining -= days * 86400;
const hours = Math.floor(remaining / 3600);
remaining -= hours * 3600;
const minutes = Math.floor(remaining / 60);
remaining -= minutes * 60;
return {days: days, hours: hours, minutes: minutes, seconds: remaining};
}
function formatRelativeTime(relative) {
if (relative.days) {
if (relative.hours) return `${relative.days}d ${relative.hours}h`;
if (relative.minutes) return `${relative.days}d ${relative.minutes}m`;
return `${relative.days}d`;
}
if (relative.hours) {
if (relative.minutes) return `${relative.hours}h ${relative.minutes}m`;
if (relative.seconds) return `${relative.hours}h ${relative.seconds}s`;
return `${relative.hours}h`;
}
if (relative.minutes) return `${relative.minutes}m ${relative.seconds}s`;
return `${relative.seconds}s`;
}
// --- torn api ----------------------------------------------------------
const getVirusApi = (timestamp) => `https://api.torn.com/v2/user/virus?comment=${plugin_name}&key=${API_KEY}×tamp=${timestamp}`;
async function getVirusInformation() {
const cached = readVirusCache();
if (cached && Date.now() < cached.expires) {
log('Using Virus cache');
virus_data = cached.data;
return virus_data;
}
try {
log('Fetching Virus information from API');
const url = getVirusApi(Math.floor(Date.now() / 1000));
const { text } = await httpGetJSON(url);
const data = JSON.parse(text);
virus_data = data.virus;
const ttl = virus_data ? (virus_data.until - Math.floor(Date.now() / 1000)) * 1000 : null;
writeVirusCache(virus_data, {ttl: ttl});
return virus_data;
}
catch (e) {
error("Events fetch failed: " + (e && e.message));
if (cached) {
virus_data = cached.data;
return virus_data;
}
return null;
}
}
// --- rest ----------------------------------------------------------
function addCSS() {
const style_code = `
li.vius-icon {
background-image: url("data:image/svg+xml;utf8,${virusCodeSvgIcon}") !important;
}
li.vius-icon.off {
background-position: -17px 0px;
}
`;
log("Adding CSS...");
let style = document.createElement("style");
style.type = "text/css";
style.innerHTML = style_code;
document.head.appendChild(style);
log("CSS added");
}
let clicked_status_icon_once = false;
function injectStatusIcon() {
onElement('ul[class^=status-icons]', (status_icon_list) => {
const last_icon = status_icon_list.lastChild;
const new_icon = last_icon.cloneNode(true);
new_icon.setAttribute('class', 'vius-icon');
if (virus_data === null) {
new_icon.classList.add('off');
}
const relative_time_text = virus_data ? formatRelativeTime(untilTimestampToRelativeTime(virus_data.until)) : '';
const a = new_icon.querySelector('a');
const tooltip_text = virus_data === null ? 'No virus being made' : `You are currently programming the ${virus_data.item.name}\nIt will be completed in ${relative_time_text}`;
a.removeAttribute('aria-label');
a.removeAttribute('tabindex');
// a.removeAttribute('data-is-tooltip-opened');
a.removeAttribute('i-data');
a.setAttribute('aria-label', tooltip_text);
a.setAttribute('title', tooltip_text);
a.setAttribute('href', 'https://www.torn.com/pc.php');
// mimic double click status icon behavior
a.addEventListener('click', (e) => {
if (!clicked_status_icon_once) {
e.preventDefault();
}
clicked_status_icon_once = !clicked_status_icon_once;
});
Array.from(status_icon_list.children).forEach((other_status_icon) => {
other_status_icon.addEventListener('click', (e) => {
clicked_status_icon_once = false;
});
});
status_icon_list.appendChild(new_icon);
});
}
function runMain() {
log("Initializing...");
addCSS();
if (/^\/pc\.php/.test(window.location.pathname)) {
log('In PC => Clearing virus data cache')
clearVirusCache();
} else {
getVirusInformation().then(()=>{injectStatusIcon();});
}
log("Done!");
}
runMain();
})();