diff --git a/api/main.js b/api/main.js index b2149202..1bbe19a6 100644 --- a/api/main.js +++ b/api/main.js @@ -139,6 +139,75 @@ let waitForSingleElements = []; var allWaitInstances = {}; let totalRunners = 0; +let scratchToolsSelectorScanTimer = null; +let scratchToolsSelectorScanInProgress = false; +let scratchToolsSelectorScanRerunRequested = false; + +function safelyRunWaitForElementsCallback(waitInstance, element) { + try { + let callbackResult = waitInstance.callback(element); + if (callbackResult && typeof callbackResult.then === "function") { + Promise.resolve(callbackResult).catch(function (error) { + ste.console.error(error, "waitForElements"); + }); + } + } catch (error) { + ste.console.error(error, "waitForElements"); + } +} + +function processWaitInstanceElements(key, waitInstance) { + if (!waitInstance || waitInstance.removed) { + delete allWaitInstances[key]; + return; + } + + if (!waitInstance.seenElements) { + waitInstance.seenElements = new WeakSet(); + } + + try { + document.querySelectorAll(waitInstance.selector).forEach(function (el) { + if (!waitInstance.seenElements.has(el)) { + waitInstance.seenElements.add(el); + waitInstance.elements.push(el); + safelyRunWaitForElementsCallback(waitInstance, el); + } + }); + } catch (error) { + ste.console.error(error, "waitForElements"); + } +} + +function processSingleWaitElements() { + waitForSingleElements = waitForSingleElements.filter(function (promise) { + if (promise.resolved) { + return false; + } + + let element = document.querySelector(promise.selector); + if (element) { + promise.resolved = true; + promise.resolve(element); + return false; + } + + return true; + }); +} + +function scheduleScratchToolsSelectorScan() { + if (scratchToolsSelectorScanTimer !== null) { + return; + } + + // Batch frequent DOM mutations into one scan per frame-ish interval. + scratchToolsSelectorScanTimer = setTimeout(function () { + scratchToolsSelectorScanTimer = null; + returnScratchToolsSelectorsMutationObserverCallbacks(); + }, 16); +} + ScratchTools.waitForElements = function (selector, callback) { totalRunners += 1; var thisRunner = "wait-" + (totalRunners - 1).toString(); @@ -150,12 +219,16 @@ ScratchTools.waitForElements = function (selector, callback) { selector, callback, elements: [], + seenElements: new WeakSet(), }; - returnScratchToolsSelectorsMutationObserverCallbacks(); + processWaitInstanceElements(thisRunner, allWaitInstances[thisRunner]); + processSingleWaitElements(); return { id: thisRunner, remove: function () { - allWaitInstances[thisRunner].removed = true; + if (allWaitInstances[thisRunner]) { + allWaitInstances[thisRunner].removed = true; + } }, }; }; @@ -169,21 +242,23 @@ ScratchTools.waitForElements("head > *", function (el) { ScratchTools.waitForElement = async function (selector) { return new Promise((resolve) => { - if (document.querySelector(selector)) { - resolve(document.querySelector(selector)); + let existingElement = document.querySelector(selector); + if (existingElement) { + resolve(existingElement); } else { waitForSingleElements.push({ selector: selector, resolved: false, resolve, }); + scheduleScratchToolsSelectorScan(); } }); }; function enableScratchToolsSelectorsMutationObserver() { var ScratchToolsSelectorsMutationObserver = new MutationObserver( - returnScratchToolsSelectorsMutationObserverCallbacks + scheduleScratchToolsSelectorScan ); ScratchToolsSelectorsMutationObserver.observe( document.querySelector("html"), @@ -194,26 +269,26 @@ function enableScratchToolsSelectorsMutationObserver() { enableScratchToolsSelectorsMutationObserver(); function returnScratchToolsSelectorsMutationObserverCallbacks() { - updateCSSFiles() - Object.keys(allWaitInstances).forEach(function (key) { - var waitInstance = allWaitInstances[key]; - if (!waitInstance.removed) { - document.querySelectorAll(waitInstance.selector).forEach(function (el) { - if (!waitInstance.elements?.includes(el)) { - allWaitInstances[key].elements.push(el); - waitInstance.callback(el); - } - }); - } - }); - waitForSingleElements - .filter((promise) => !promise.resolved) - .forEach(function (promise) { - if (document.querySelector(promise.selector)) { - promise.resolved = true; - promise.resolve(document.querySelector(promise.selector)); - } + if (scratchToolsSelectorScanInProgress) { + scratchToolsSelectorScanRerunRequested = true; + return; + } + + scratchToolsSelectorScanInProgress = true; + try { + updateCSSFiles(); + Object.keys(allWaitInstances).forEach(function (key) { + processWaitInstanceElements(key, allWaitInstances[key]); }); + processSingleWaitElements(); + } finally { + scratchToolsSelectorScanInProgress = false; + } + + if (scratchToolsSelectorScanRerunRequested) { + scratchToolsSelectorScanRerunRequested = false; + scheduleScratchToolsSelectorScan(); + } } ScratchTools.createModal = function (titleText, description, buttons) { diff --git a/api/module.js b/api/module.js index 71dcf878..1d7a0baf 100644 --- a/api/module.js +++ b/api/module.js @@ -25,88 +25,149 @@ function className(name) { return "ste-" + name.toLowerCase().replaceAll(" ", "-") } -ScratchTools.modules.forEach(async function (script) { - var feature = await import(ScratchTools.dir + "/api/feature/index.js"); - var shouldBeRun = true; - if (script.runOn) { - shouldBeRun = !!new URL(window.location.href).pathname.match(script.runOn); +async function ensureFeatureRuntimeData(featureId) { + if (!featureId || !Array.isArray(ScratchTools?.Features?.data)) { + return; + } + + let existing = ScratchTools.Features.data.find(function (el) { + return el.id === featureId; + }); + + let needsRuntimeData = + !existing || + !Array.isArray(existing.resources) || + !Array.isArray(existing.options); + + if (!needsRuntimeData) { + return; + } + + let featureData = null; + try { + let response = await fetch(`${ScratchTools.dir}/features/${featureId}/data.json`); + if (response.ok) { + featureData = await response.json(); + } + } catch (error) {} + + if (!featureData) { + if (!existing) { + ScratchTools.Features.data.push({ + id: featureId, + resources: [], + options: [], + localesData: {}, + }); + } + return; } - if (script.pageType) { - var pageType = document.querySelector("#app") ? 3 : 2; - shouldBeRun = pageType === script.pageType; + + let merged = Object.assign({}, existing || {}, featureData); + merged.id = featureId; + merged.resources = Array.isArray(merged.resources) ? merged.resources : []; + merged.options = Array.isArray(merged.options) ? merged.options : []; + merged.localesData = merged.localesData || existing?.localesData || {}; + + if (existing) { + Object.assign(existing, merged); + } else { + ScratchTools.Features.data.push(merged); } - if (shouldBeRun) { - if (!alreadyInjected.includes(script.file)) { - alreadyInjected.push(script.file); - var fun = await import(script.file); - if (fun.default) { - var featureGenerated = feature.default(script.feature) - allFeatures.push(featureGenerated) - fun.default({ - feature: featureGenerated, - scratchClass, - className, - console: { - log: function (content) { - ste.console.log(content, script.feature.id); - }, - warn: function (content) { - ste.console.warn(content, script.feature.id); - }, - error: function (content) { - ste.console.error(content, script.feature.id); - }, - }, - }); +} + +function runFeatureEntry(fun, script, featureGenerated) { + let featureLabel = script?.feature?.id || script?.file || "module-loader"; + try { + let response = fun.default({ + feature: featureGenerated, + scratchClass, + className, + console: { + log: function (content) { + ste.console.log(content, featureLabel); + }, + warn: function (content) { + ste.console.warn(content, featureLabel); + }, + error: function (content) { + ste.console.error(content, featureLabel); + }, + }, + }); + + if (response && typeof response.then === "function") { + Promise.resolve(response).catch(function (error) { + ste.console.error(error, featureLabel); + }); + } + } catch (error) { + ste.console.error(error, featureLabel); + } +} + +ScratchTools.modules.forEach(async function (script) { + try { + await ensureFeatureRuntimeData(script?.feature?.id); + var feature = await import(ScratchTools.dir + "/api/feature/index.js"); + var shouldBeRun = true; + if (script.runOn) { + shouldBeRun = !!new URL(window.location.href).pathname.match(script.runOn); + } + if (script.pageType) { + var pageType = document.querySelector("#app") ? 3 : 2; + shouldBeRun = pageType === script.pageType; + } + if (shouldBeRun) { + if (!alreadyInjected.includes(script.file)) { + alreadyInjected.push(script.file); + var fun = await import(script.file); + if (fun.default) { + var featureGenerated = feature.default(script.feature) + allFeatures.push(featureGenerated) + runFeatureEntry(fun, script, featureGenerated); + } } } + } catch (error) { + ste.console.error(error, script?.feature?.id || script?.file || "module-loader"); } }); ScratchTools.injectModule = async function (script) { - var feature = await import(ScratchTools.dir + "/api/feature/index.js"); - var shouldBeRun = true; - if (script.runOn) { - shouldBeRun = !!new URL(window.location.href).pathname.match(script.runOn); - } - if (script.pageType) { - var pageType = document.querySelector("#app") ? 3 : 2; - shouldBeRun = pageType === script.pageType; - } - if (shouldBeRun) { - if (!alreadyInjected.includes(script.file)) { - alreadyInjected.push(script.file); - var fun = await import(script.file); - if (fun.default) { - var featureGenerated = feature.default(script.feature) - allFeatures.push(featureGenerated) - fun.default({ - feature: featureGenerated, - scratchClass, - className, - console: { - log: function (content) { - ste.console.log(content, script.feature.id); - }, - warn: function (content) { - ste.console.warn(content, script.feature.id); - }, - error: function (content) { - ste.console.error(content, script.feature.id); - }, - }, - }); + try { + await ensureFeatureRuntimeData(script?.feature?.id); + var feature = await import(ScratchTools.dir + "/api/feature/index.js"); + var shouldBeRun = true; + if (script.runOn) { + shouldBeRun = !!new URL(window.location.href).pathname.match(script.runOn); + } + if (script.pageType) { + var pageType = document.querySelector("#app") ? 3 : 2; + shouldBeRun = pageType === script.pageType; + } + if (shouldBeRun) { + if (!alreadyInjected.includes(script.file)) { + alreadyInjected.push(script.file); + var fun = await import(script.file); + if (fun.default) { + var featureGenerated = feature.default(script.feature) + allFeatures.push(featureGenerated) + runFeatureEntry(fun, script, featureGenerated); + } + } else { + allFeatures.filter((el) => el.self.id === script.feature.id).forEach(function(el) { + el.self.enabled = true + }) + ScratchTools.managedElements.filter((el) => el.feature === script.feature.id).forEach(function(el) { + if (!el.element) return; + el.element.style.display = el?.previousDisplay || null + }) + allEnableFunctions[script.feature.id]?.(); } - } else { - allFeatures.filter((el) => el.self.id === script.feature.id).forEach(function(el) { - el.self.enabled = true - }) - ScratchTools.managedElements.filter((el) => el.feature === script.feature.id).forEach(function(el) { - if (!el.element) return; - el.element.style.display = el?.previousDisplay || null - }) - allEnableFunctions[script.feature.id]?.(); } + } catch (error) { + ste.console.error(error, script?.feature?.id || script?.file || "module-loader"); } }; diff --git a/extras/background.js b/extras/background.js index 9c6a10d5..78d1e65e 100644 --- a/extras/background.js +++ b/extras/background.js @@ -1,13 +1,46 @@ -let cachedStorage; -let cachedStyles; +let cachedStorage = ""; +let cachedStyles = []; +let cachedFeatureDataById = {}; +let cachedScripts = []; +let cacheReady = false; +let cachePromise = null; async function cache() { cachedStorage = (await chrome.storage.sync.get("features"))?.features || ""; - cachedStyles = await getEnabledStyles(); + cachedFeatureDataById = {}; cachedScripts = await getModules(); + cachedStyles = await getEnabledStyles(); + cacheReady = true; return true; } -cache(); + +function ensureCacheReady(forceRefresh = false) { + if (forceRefresh) { + cacheReady = false; + } + + if (cacheReady) { + return Promise.resolve(true); + } + + if (!cachePromise) { + cachePromise = cache() + .catch(function () { + cachedStorage = ""; + cachedFeatureDataById = {}; + cachedScripts = []; + cachedStyles = []; + return false; + }) + .finally(function () { + cachePromise = null; + }); + } + + return cachePromise; +} + +ensureCacheReady(); async function checkBetaUpdates() { var loggedIn = await ( @@ -180,7 +213,7 @@ chrome.runtime.onInstalled.addListener(async function (object) { chrome.storage.onChanged.addListener(async function (changes, namespace) { for (let [key, { oldValue, newValue }] of Object.entries(changes)) { if (key === "features") { - await cache(); + await ensureCacheReady(true); let features = await getFeaturesCode(); let username = await getUsername(); let time = @@ -216,6 +249,12 @@ chrome.storage.onChanged.addListener(async function (changes, namespace) { chrome.tabs.onUpdated.addListener(async function (tabId, info) { var tab = await chrome.tabs.get(tabId); + if ( + info.status === "loading" && + tab?.url?.startsWith("https://scratch.mit.edu/") + ) { + await ensureCacheReady(); + } if (tab?.url?.startsWith("https://scratch.mit.edu/")) { var obj = await chrome.storage.sync.get("features"); if (obj.features && obj.features.includes("isonline")) { @@ -261,8 +300,8 @@ chrome.tabs.onUpdated.addListener(async function (tabId, info) { } } var listOfIds = []; - var features = await (await fetch("/features/features.json")).json(); - features.forEach(function (feature) { + var allFeatures = await (await fetch("/features/features.json")).json(); + allFeatures.forEach(function (feature) { listOfIds.push(feature.file || feature.id); }); if ( @@ -357,10 +396,30 @@ chrome.tabs.onUpdated.addListener(async function (tabId, info) { ]; console.log("%cScratchTools", styleArray.join(";"), text); }; - async function getCurrentTab() { + async function getCurrentTab(data) { ScratchTools.console.log("STARTING."); - var response = await fetch("/features/features.json"); - var data = await response.json(); + + // Prevent duplicate API bootstrap on the same document. + let bootstrapLock = await chrome.scripting.executeScript({ + target: { tabId: tabId }, + func: claimScratchToolsBootstrap, + world: "MAIN", + }); + + if (!bootstrapLock?.[0]?.result) { + ScratchTools.console.log("Skipped duplicate bootstrap for page."); + return; + } + + function claimScratchToolsBootstrap() { + let bootstrapKey = "__scratchtools_bootstrap_lock_v1__"; + if (window[bootstrapKey]) { + return false; + } + window[bootstrapKey] = true; + return true; + } + var uiLanguage = chrome.i18n.getUILanguage() || "en"; if (uiLanguage.includes("-")) { uiLanguage = uiLanguage.split("-")[0]; @@ -379,101 +438,182 @@ chrome.tabs.onUpdated.addListener(async function (tabId, info) { } await chrome.scripting.executeScript({ target: { tabId: tabId }, - files: [`/api/main.js`], - world: "MAIN", - }); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/verify.js`], - world: "MAIN", - }); - ScratchTools.console.log("Injected main API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/modals.js`], - world: "MAIN", - }); - ScratchTools.console.log("Injected modals API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/feature.js`], - world: "MAIN", - }); - ScratchTools.console.log("Injected feature API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/auth.js`], - world: "MAIN", - }); - ScratchTools.console.log("Injected auth API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/logging.js`], - world: "MAIN", - }); - ScratchTools.console.log("Injected logging API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/vm.js`], - world: "MAIN", - }); - ScratchTools.console.log("Injected Scratch API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/cookies.js`], - world: "MAIN", - }); - ScratchTools.console.log("Injected cookies API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/getScratch.js`], + files: [ + `/api/main.js`, + `/api/verify.js`, + `/api/modals.js`, + `/api/feature.js`, + `/api/auth.js`, + `/api/logging.js`, + `/api/vm.js`, + `/api/cookies.js`, + `/api/getScratch.js`, + `/api/spaces.js`, + ], world: "MAIN", }); - ScratchTools.console.log("Injected getScratch API."); - await chrome.scripting.executeScript({ - target: { tabId: tabId }, - files: [`/api/spaces.js`], - world: "MAIN", + ScratchTools.console.log("Injected core APIs."); + var language = uiLanguage; + var featureDataById = {}; + var featureLocalesById = {}; + var enabledFeatureIds = new Set( + (cachedScripts || []) + .map(function (script) { + return script?.feature?.id || null; + }) + .filter(function (id) { + return !!id; + }) + ); + + async function getAllFeatureLocales(localeCode) { + async function readLocaleFile(targetLocale) { + try { + let localeResponse = await fetch( + `/extras/feature-locales/${targetLocale}.json` + ); + if (localeResponse.ok) { + return await localeResponse.json(); + } + } catch (err) {} + return null; + } + + let localized = await readLocaleFile(localeCode); + if (localized) { + return localized; + } + + if (localeCode !== "en") { + let english = await readLocaleFile("en"); + if (english) { + return english; + } + } + + return {}; + } + + function normalizeLocaleMessage(value) { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + return value.message || ""; + } + return ""; + } + + let allFeatureLocales = await getAllFeatureLocales(language); + Object.keys(allFeatureLocales || {}).forEach(function (fullKey) { + let slashIndex = fullKey.indexOf("/"); + if (slashIndex === -1) { + return; + } + + let featureId = fullKey.slice(0, slashIndex); + if (!featureLocalesById[featureId]) { + featureLocalesById[featureId] = {}; + } + + featureLocalesById[featureId][fullKey] = normalizeLocaleMessage( + allFeatureLocales[fullKey] + ); }); - ScratchTools.console.log("Injected spaces API."); - var newFullData = []; - for (var i in data) { - var feature = data[i]; - if (feature.version === 2) { - var featureData = await ( - await fetch(`/features/${feature.id}/data.json`) - ).json(); - featureData.id = feature.id; - featureData.version = feature.version; - if (chrome.i18n.getUILanguage().includes("-")) { - var language = chrome.i18n.getUILanguage().split("-")[0]; - } else { - var language = chrome.i18n.getUILanguage(); + + async function getLocalesData(featureId) { + if (featureLocalesById[featureId]) { + return featureLocalesById[featureId]; + } + + try { + let localizedResponse = await fetch( + `/feature-locales/${featureId}/${language}.json` + ); + if (localizedResponse.ok) { + return await localizedResponse.json(); } - let localesData = {}; - try { - localesData = await ( - await fetch( - `/feature-locales/${featureData.id}/${language}.json` - ) - ).json(); - } catch (err) { + } catch (err) {} + + try { + let englishResponse = await fetch( + `/feature-locales/${featureId}/en.json` + ); + if (englishResponse.ok) { + return await englishResponse.json(); + } + } catch (err) {} + + return {}; + } + + async function getFeatureRuntimeData(feature) { + // Keep base metadata from features.json, then fill runtime data if enabled. + let featureData = Object.assign({}, feature); + let isEnabledFeature = + enabledFeatureIds.has(feature.id) || + (cachedStorage || "").includes(feature.id); + + if (isEnabledFeature) { + let cachedFeatureData = cachedFeatureDataById[feature.id] || null; + + if (!cachedFeatureData || !Object.keys(cachedFeatureData).length) { try { - localesData = await ( - await fetch(`/feature-locales/${featureData.id}/en.json`) - ).json(); + let featureResponse = await fetch( + `/features/${feature.id}/data.json` + ); + if (featureResponse.ok) { + cachedFeatureData = await featureResponse.json(); + cachedFeatureDataById[feature.id] = cachedFeatureData; + } } catch (err) {} } - let locales = {}; - Object.keys(localesData).forEach(function (el) { - locales[`${featureData.id}/${el}`] = localesData[el]; - }); - featureData.localesData = locales; - newFullData.push(featureData); + + if (cachedFeatureData && Object.keys(cachedFeatureData).length) { + featureData = Object.assign(featureData, cachedFeatureData); + } + } + + featureData.id = feature.id; + featureData.version = feature.version; + featureData.resources = Array.isArray(featureData.resources) + ? featureData.resources + : []; + featureData.options = Array.isArray(featureData.options) + ? featureData.options + : []; + + if (isEnabledFeature) { + let localesData = await getLocalesData(feature.id); + if (featureLocalesById[feature.id]) { + featureData.localesData = featureLocalesById[feature.id]; + } else { + let locales = {}; + Object.keys(localesData || {}).forEach(function (el) { + locales[`${feature.id}/${el}`] = normalizeLocaleMessage( + localesData[el] + ); + }); + featureData.localesData = locales; + } } else { - newFullData.push(feature); + featureData.localesData = {}; } + + return featureData; } + + var newFullData = await Promise.all( + data.map(async function (feature) { + if (feature.version !== 2) { + return feature; + } + + let featureData = await getFeatureRuntimeData(feature); + featureDataById[feature.id] = featureData; + return featureData; + }) + ); await chrome.scripting.executeScript({ args: [ newFullData, @@ -527,35 +667,44 @@ chrome.tabs.onUpdated.addListener(async function (tabId, info) { } addData(); injectStyles(tabId); + var resourcesToInject = []; for (var i in data) { var feature = data[i]; if (feature.version === 2) { - var featureData = await ( - await fetch(`/features/${feature.id}/data.json`) - ).json(); - for (var resource in featureData.resources) { - await chrome.scripting.executeScript({ - args: [ - featureData.resources[resource].name, - chrome.runtime.getURL( - `/features/${feature.id}${featureData.resources[resource].path}` - ), - ], - target: { tabId: tabId }, - func: injectResource, - world: "MAIN", - }); - function injectResource(name, path) { - ScratchTools.Resources[name] = path; - var style = document.createElement("style"); - style.textContent = `:root { - --scratchtoolsresource-${name}: url(${path}); - }`; - document.body.appendChild(style); + var featureData = featureDataById[feature.id]; + var resources = featureData?.resources || []; + for (var resource in resources) { + if (!resources[resource]?.name || !resources[resource]?.path) { + continue; } + resourcesToInject.push({ + name: resources[resource].name, + path: chrome.runtime.getURL( + `/features/${feature.id}${resources[resource].path}` + ), + }); } } } + + if (resourcesToInject.length > 0) { + await chrome.scripting.executeScript({ + args: [resourcesToInject], + target: { tabId: tabId }, + func: injectResources, + world: "MAIN", + }); + } + function injectResources(resources) { + resources.forEach(function (resource) { + ScratchTools.Resources[resource.name] = resource.path; + var style = document.createElement("style"); + style.textContent = `:root { + --scratchtoolsresource-${resource.name}: url(${resource.path}); + }`; + document.body.appendChild(style); + }); + } var langData = {}; try { var langDataFetched = await ( @@ -587,12 +736,9 @@ chrome.tabs.onUpdated.addListener(async function (tabId, info) { world: "MAIN", }); async function addData() { - var allStorage = {}; await data.forEach(async function (el) { if (el.version === 2) { - el.options = ( - await (await fetch(`/features/${el.id}/data.json`)).json() - ).options; + el.options = (featureDataById[el.id] || {}).options || []; } if (el.options !== undefined) { await el.options.forEach(async function (option) { @@ -653,7 +799,7 @@ chrome.tabs.onUpdated.addListener(async function (tabId, info) { } }); } - getCurrentTab(); + getCurrentTab(allFeatures); } } }); @@ -700,25 +846,36 @@ chrome.runtime.onMessageExternal.addListener(async function ( await chrome.tabs.update(sender.tab.id, { active: true }); } if (msg.source === "message-api") { + async function sendMessageApiResponse(content, uuid) { + await chrome.scripting.executeScript({ + args: [content, uuid], + target: { tabId: sender.tab.id }, + func: resolveMessageApiPromise, + world: "MAIN", + }); + function resolveMessageApiPromise(response, responseUuid) { + let index = ScratchTools.MESSAGES.findIndex( + (el) => el.uuid === responseUuid + ); + if (index === -1) { + return; + } + let message = ScratchTools.MESSAGES[index]; + ScratchTools.MESSAGES.splice(index, 1); + message.resolve(response); + } + } + if (msg.message?.startsWith("request-perms")) { let perms = msg.content; chrome.permissions.request({ permissions: perms }, async (granted) => { let isComplete = !!granted; - await chrome.scripting.executeScript({ - args: [isComplete, msg.uuid], - target: { tabId: sender.tab.id }, - func: sendPermsResponse, - world: "MAIN", - }); - function sendPermsResponse(completed, uuid) { - ScratchTools.MESSAGES.find((el) => el.uuid === uuid).resolve( - completed - ); - } + await sendMessageApiResponse(isComplete, msg.uuid); }); } + } if (typeof msg === "object") { if (msg.message === "storageSet") { @@ -751,9 +908,20 @@ async function getEnabledStyles() { ); for (var i in data) { var feature = data[i]; - var styles = ( - await (await fetch(`/features/${feature.id}/data.json`)).json() - ).styles; + var featureData = cachedFeatureDataById[feature.id]; + if (!featureData) { + try { + var featureResponse = await fetch(`/features/${feature.id}/data.json`); + if (featureResponse.ok) { + featureData = await featureResponse.json(); + cachedFeatureDataById[feature.id] = featureData; + } + } catch (err) { + featureData = {}; + } + } + + var styles = featureData?.styles; if (styles) { for (var i2 in styles) { styles[i2].feature = feature; @@ -770,9 +938,20 @@ async function getModules() { ); for (var i in data) { var feature = data[i]; - var scripts = - (await (await fetch(`/features/${feature.id}/data.json`)).json()) - .scripts || []; + var featureData = cachedFeatureDataById[feature.id]; + if (!featureData) { + try { + var featureResponse = await fetch(`/features/${feature.id}/data.json`); + if (featureResponse.ok) { + featureData = await featureResponse.json(); + cachedFeatureDataById[feature.id] = featureData; + } + } catch (err) { + featureData = {}; + } + } + + var scripts = featureData?.scripts || []; if (scripts) { for (var i2 in scripts) { scripts[i2].feature = feature; @@ -785,44 +964,55 @@ async function getModules() { } return allScripts; } -chrome.runtime.onMessage.addListener(async function ( - msg, - sender, - sendResponse -) { +chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { if (msg.msg === "openSupportAuth") { chrome.tabs.create({ url: "https://scratch.mit.edu/scratchtools/support/auth/", }); } if (msg.action === "getStyles") { - sendResponse({ data: cachedStyles }); + if (cacheReady) { + sendResponse({ data: cachedStyles }); + return false; + } + + ensureCacheReady() + .then(function () { + sendResponse({ data: cachedStyles }); + }) + .catch(function () { + sendResponse({ data: [] }); + }); + return true; } if (msg?.text === "get-logged-in-user") { sendResponse(true); - const data = await ( - await fetch("https://scratch.mit.edu/session/", { - headers: { - accept: "*/*", - "accept-language": "en, en;q=0.8", - "sec-ch-ua": - '"Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"macOS"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - "x-requested-with": "XMLHttpRequest", - }, - referrer: "https://scratch.mit.edu/", - referrerPolicy: "strict-origin-when-cross-origin", - body: null, - method: "GET", - mode: "cors", - credentials: "include", - }) - ).json(); - await chrome.tabs.sendMessage(sender.tab.id, data, function (response) {}); + (async function () { + const data = await ( + await fetch("https://scratch.mit.edu/session/", { + headers: { + accept: "*/*", + "accept-language": "en, en;q=0.8", + "sec-ch-ua": + '"Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "x-requested-with": "XMLHttpRequest", + }, + referrer: "https://scratch.mit.edu/", + referrerPolicy: "strict-origin-when-cross-origin", + body: null, + method: "GET", + mode: "cors", + credentials: "include", + }) + ).json(); + await chrome.tabs.sendMessage(sender.tab.id, data, function (response) {}); + })(); + return true; } }); diff --git a/extras/inject-styles.js b/extras/inject-styles.js index 744da314..7261d1c7 100644 --- a/extras/inject-styles.js +++ b/extras/inject-styles.js @@ -1,35 +1,79 @@ +function ensureStylesContainer() { + var existingContainer = document.querySelector(".scratchtools-styles-div"); + if (existingContainer) { + return existingContainer; + } + + var container = document.createElement("div"); + container.className = "scratchtools-styles-div"; + (document.head || document.documentElement).appendChild(container); + return container; +} + async function getAllUserstyles() { - var div = document.createElement("div"); - div.className = "scratchtools-styles-div"; - document.head.appendChild(div); var styles = await getStyles(); + if (!Array.isArray(styles) || styles.length === 0) { + return; + } + + var container = ensureStylesContainer(); + var existingHrefs = new Set( + Array.from(container.querySelectorAll("link[rel='stylesheet']")).map( + function (link) { + return link.href; + } + ) + ); + var fragment = document.createDocumentFragment(); + styles.forEach(function (style) { - if (window.location.pathname.match(style.runOn)) { - var link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = chrome.runtime.getURL(`/features/${style.feature.id}/${style.file}`); - link.dataset.feature = style.feature.id; - document.querySelector(".scratchtools-styles-div").appendChild(link); + if (!style || !style.feature || !style.feature.id || !style.file || !style.runOn) { + return; } + if (!window.location.pathname.match(style.runOn)) { + return; + } + + var href = chrome.runtime.getURL(`/features/${style.feature.id}/${style.file}`); + if (existingHrefs.has(href)) { + return; + } + + var link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + link.dataset.feature = style.feature.id; + fragment.appendChild(link); + existingHrefs.add(href); }); + + container.appendChild(fragment); } var injectStylesWaitForHead = new MutationObserver(injectStyles); -injectStylesWaitForHead.observe(document.querySelector("html"), { - childList: true, -}); +if (document.documentElement) { + injectStylesWaitForHead.observe(document.documentElement, { + childList: true, + }); +} +injectStyles(); async function injectStyles() { if (document.head) { injectStylesWaitForHead.disconnect(); + ensureStylesContainer(); getAllUserstyles(); } } async function getStyles() { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { chrome.runtime.sendMessage({ action: "getStyles" }, function (response) { - resolve(response.data || ""); + if (chrome.runtime.lastError) { + resolve([]); + return; + } + resolve(Array.isArray(response && response.data) ? response.data : []); }); }); }