diff --git a/src/components/tw-security-manager-modal/security-manager-modal.jsx b/src/components/tw-security-manager-modal/security-manager-modal.jsx index 761298b54..71a610dbb 100644 --- a/src/components/tw-security-manager-modal/security-manager-modal.jsx +++ b/src/components/tw-security-manager-modal/security-manager-modal.jsx @@ -5,6 +5,7 @@ import Box from '../box/box.jsx'; import Modal from '../../containers/modal.jsx'; import SecurityModals from '../../lib/tw-security-manager-constants'; import LoadExtensionModal from './load-extension.jsx'; +import UnsandboxModal from './unsandbox.jsx'; import FetchModal from './fetch.jsx'; import OpenWindowModal from './open-window.jsx'; import RedirectModal from './redirect.jsx'; @@ -39,6 +40,8 @@ const SecurityManagerModalComponent = props => ( {props.type === SecurityModals.LoadExtension ? ( + ) : props.type === SecurityModals.Unsandbox ? ( + ) : props.type === SecurityModals.Fetch ? ( ) : props.type === SecurityModals.OpenWindow ? ( diff --git a/src/components/tw-security-manager-modal/unsandbox.jsx b/src/components/tw-security-manager-modal/unsandbox.jsx new file mode 100644 index 000000000..aff227b63 --- /dev/null +++ b/src/components/tw-security-manager-modal/unsandbox.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; +import {APP_NAME} from '../../lib/brand'; + +const UnsandboxModal = props => ( +
+

+ +

+

+ +

+
+); + +UnsandboxModal.propTypes = { + extensionName: PropTypes.string.isRequired +}; + +export default UnsandboxModal; diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 1a9370850..0302f8c17 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -144,6 +144,23 @@ class Blocks extends React.Component { this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures; this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); + // Bridge FieldCustom from Closure-compiled Blockly → ScratchBlocks for the JS extension. + // The develop-builds bundle defines window.Blockly.FieldCustom, while extensions call + // ScratchBlocks.FieldCustom.registerInput(...). This assignment wires them together. + if (window.Blockly && window.Blockly.FieldCustom) { + this.ScratchBlocks.FieldCustom = window.Blockly.FieldCustom; + } + // Temporary guard: keep UI stable even if bundles momentarily lack FieldCustom. + if (this.ScratchBlocks && !this.ScratchBlocks.FieldCustom) { + this.ScratchBlocks.FieldCustom = { + registerInput: () => {}, + unregisterInput: () => {}, + getRegisteredInputs: () => new Map() + }; + log.warn('ScratchBlocks.FieldCustom is not available; using stub implementation. ' + + 'This usually means the Blockly → ScratchBlocks FieldCustom bridge is not configured correctly.'); + } + const Msg = this.ScratchBlocks.Msg; Msg.PROCEDURES_RETURN = this.props.intl.formatMessage(messages.PROCEDURES_RETURN, { v: '%1' diff --git a/src/containers/extension-library.jsx b/src/containers/extension-library.jsx index 4f986767b..6cc7cd641 100644 --- a/src/containers/extension-library.jsx +++ b/src/containers/extension-library.jsx @@ -8,7 +8,10 @@ import log from '../lib/log'; import extensionLibraryContent, { galleryError, galleryLoading, - galleryMore + galleryMore, + galleryLoadingOB, + galleryMoreOB, + galleryErrorOB } from '../lib/libraries/extensions/index.jsx'; import extensionTags from '../lib/libraries/tw-extension-tags'; @@ -39,9 +42,57 @@ const translateGalleryItem = (extension, locale) => ({ description: extension.descriptionTranslations[locale] || extension.description }); -let cachedGallery = null; +// Timeout constant for gallery loading +const GALLERY_TIMEOUT_MS = 750; -const fetchLibrary = async () => { +// Common gallery fetcher function to reduce code duplication +let cachedGalleryTW = null; +let cachedGalleryOB = null; + +const fetchLibraryTW = async () => { + const res = await fetch('https://extensions.turbowarp.org/generated-metadata/extensions-v0.json'); + if (!res.ok) { + throw new Error(`HTTP status ${res.status}`); + } + const data = await res.json(); + return data.extensions.map(extension => ({ + name: extension.name, + nameTranslations: extension.nameTranslations || {}, + description: extension.description, + descriptionTranslations: extension.descriptionTranslations || {}, + extensionId: extension.id, + extensionURL: `https://extensions.turbowarp.org/${extension.slug}.js`, + iconURL: `https://extensions.turbowarp.org/${extension.image || 'images/unknown.svg'}`, + tags: ['tw'], + credits: [ + ...(extension.original || []), + ...(extension.by || []) + ].map(credit => { + if (credit.link) { + return ( + + {credit.name} + + ); + } + return credit.name; + }), + docsURI: extension.docs ? `https://extensions.turbowarp.org/${extension.slug}` : null, + samples: extension.samples ? extension.samples.map(sample => ({ + href: `${process.env.ROOT}editor?project_url=https://extensions.turbowarp.org/samples/${encodeURIComponent(sample)}.sb3`, + text: sample + })) : null, + incompatibleWithScratch: !extension.scratchCompatible, + featured: true + })); +}; + +const fetchLibraryOB = async () => { const res = await fetch('https://omniblocks.github.io/extensions/generated-metadata/extensions-v0.json'); if (!res.ok) { throw new Error(`HTTP status ${res.status}`); @@ -55,7 +106,7 @@ const fetchLibrary = async () => { extensionId: extension.id, extensionURL: `https://omniblocks.github.io/extensions/${extension.slug}.js`, iconURL: `https://omniblocks.github.io/extensions/${extension.image || 'images/unknown.svg'}`, - tags: ['tw'], + tags: ['ob'], credits: [ ...(extension.original || []), ...(extension.by || []) @@ -84,6 +135,30 @@ const fetchLibrary = async () => { })); }; +// Helper function to handle gallery loading with timeout +const loadGalleryWithTimeout = (fetchFunction, timeoutCallback, successCallback, errorCallback) => { + let timeoutFired = false; + const timeout = setTimeout(() => { + timeoutFired = true; + timeoutCallback(); + }, GALLERY_TIMEOUT_MS); + + fetchFunction() + .then(gallery => { + if (!timeoutFired) { + successCallback(gallery); + } + clearTimeout(timeout); + }) + .catch(error => { + if (!timeoutFired) { + log.error(error); + errorCallback(error); + } + clearTimeout(timeout); + }); +}; + class ExtensionLibrary extends React.PureComponent { constructor (props) { super(props); @@ -91,34 +166,39 @@ class ExtensionLibrary extends React.PureComponent { 'handleItemSelect' ]); this.state = { - gallery: cachedGallery, - galleryError: null, - galleryTimedOut: false + galleryTW: cachedGalleryTW, + galleryOB: cachedGalleryOB, + galleryTWError: null, + galleryOBError: null, + galleryTWTimedOut: false, + galleryOBTimedOut: false }; } componentDidMount () { - if (!this.state.gallery) { - const timeout = setTimeout(() => { - this.setState({ - galleryTimedOut: true - }); - }, 750); - - fetchLibrary() - .then(gallery => { - cachedGallery = gallery; - this.setState({ - gallery - }); - clearTimeout(timeout); - }) - .catch(error => { - log.error(error); - this.setState({ - galleryError: error - }); - clearTimeout(timeout); - }); + // Fetch TurboWarp gallery if not cached + if (!this.state.galleryTW) { + loadGalleryWithTimeout( + fetchLibraryTW, + () => this.setState({galleryTWTimedOut: true}), + gallery => { + cachedGalleryTW = gallery; + this.setState({galleryTW: gallery}); + }, + error => this.setState({galleryTWError: error}) + ); + } + + // Fetch OmniBlocks gallery if not cached + if (!this.state.galleryOB) { + loadGalleryWithTimeout( + fetchLibraryOB, + () => this.setState({galleryOBTimedOut: true}), + gallery => { + cachedGalleryOB = gallery; + this.setState({galleryOB: gallery}); + }, + error => this.setState({galleryOBError: error}) + ); } } handleItemSelect (item) { @@ -157,23 +237,40 @@ class ExtensionLibrary extends React.PureComponent { } } render () { - let library = null; - if (this.state.gallery || this.state.galleryError || this.state.galleryTimedOut) { - library = extensionLibraryContent.map(toLibraryItem); - library.push('---'); - if (this.state.gallery) { - library.push(toLibraryItem(galleryMore)); - const locale = this.props.intl.locale; - library.push( - ...this.state.gallery - .map(i => translateGalleryItem(i, locale)) - .map(toLibraryItem) - ); - } else if (this.state.galleryError) { - library.push(toLibraryItem(galleryError)); - } else { - library.push(toLibraryItem(galleryLoading)); - } + const library = extensionLibraryContent.map(toLibraryItem); + library.push('---'); + + const locale = this.props.intl.locale; + + // Add TurboWarp gallery items + if (this.state.galleryTW) { + library.push(toLibraryItem(galleryMore)); + library.push( + ...this.state.galleryTW + .map(i => translateGalleryItem(i, locale)) + .map(toLibraryItem) + ); + } else if (this.state.galleryTWError) { + library.push(toLibraryItem(galleryError)); + } else if (this.state.galleryTWTimedOut) { + library.push(toLibraryItem(galleryLoading)); + } + + // Add separator between galleries + library.push('---'); + + // Add OmniBlocks gallery items + if (this.state.galleryOB) { + library.push(toLibraryItem(galleryMoreOB)); + library.push( + ...this.state.galleryOB + .map(i => translateGalleryItem(i, locale)) + .map(toLibraryItem) + ); + } else if (this.state.galleryOBError) { + library.push(toLibraryItem(galleryErrorOB)); + } else if (this.state.galleryOBTimedOut) { + library.push(toLibraryItem(galleryLoadingOB)); } return ( diff --git a/src/containers/tw-security-manager.jsx b/src/containers/tw-security-manager.jsx index 19a2fe340..c0d21ed5e 100644 --- a/src/containers/tw-security-manager.jsx +++ b/src/containers/tw-security-manager.jsx @@ -134,6 +134,7 @@ let allowedGeolocation = false; const SECURITY_MANAGER_METHODS = [ 'getSandboxMode', 'canLoadExtensionFromProject', + 'canUnsandbox', 'canFetch', 'canOpenWindow', 'canRedirect', @@ -283,6 +284,17 @@ class TWSecurityManagerComponent extends React.Component { }); } + /** + * @param {string} extensionName The extension's display name + * @returns {Promise} True if the extension can run without sandbox + */ + async canUnsandbox (extensionName) { + const {showModal} = await this.acquireModalLock(); + return showModal(SecurityModals.Unsandbox, { + extensionName + }); + } + /** * @param {string} url The resource to fetch * @returns {Promise} True if the resource is allowed to be fetched diff --git a/src/lib/libraries/extensions/gallery/obgallery.svg b/src/lib/libraries/extensions/gallery/obgallery.svg new file mode 100644 index 000000000..d0cb26a5d --- /dev/null +++ b/src/lib/libraries/extensions/gallery/obgallery.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/src/lib/libraries/extensions/index.jsx b/src/lib/libraries/extensions/index.jsx index bbf273884..04cb0ee20 100644 --- a/src/lib/libraries/extensions/index.jsx +++ b/src/lib/libraries/extensions/index.jsx @@ -46,10 +46,13 @@ import gdxforInsetIconURL from './gdxfor/gdxfor-small.svg'; import gdxforConnectionIconURL from './gdxfor/gdxfor-illustration.svg'; import gdxforConnectionSmallIconURL from './gdxfor/gdxfor-small.svg'; +import jgJavascriptExtensionIcon from './javascript/javascript.png'; + import twIcon from './tw/tw.svg'; import customExtensionIcon from './custom/custom.svg'; import returnIcon from './custom/return.svg'; import galleryIcon from './gallery/gallery.svg'; +import obgalleryIcon from './gallery/obgallery.svg'; import {APP_NAME} from '../../brand'; export default [ @@ -360,11 +363,11 @@ export default [ { name: ( ), @@ -381,6 +384,27 @@ export default [ tags: ['tw'], featured: true }, + { + name: ( + + ), + extensionId: 'SPjavascriptV2', + iconURL: jgJavascriptExtensionIcon, + description: ( + + ), + incompatibleWithScratch: true, + tags: ['ob'], + featured: true + }, { name: ( ), href: 'https://extensions.turbowarp.org/', @@ -433,12 +454,9 @@ export const galleryLoading = { export const galleryMore = { name: ( ), href: 'https://extensions.turbowarp.org/', @@ -459,12 +477,9 @@ export const galleryMore = { export const galleryError = { name: ( ), href: 'https://extensions.turbowarp.org/', @@ -481,3 +496,70 @@ export const galleryError = { tags: ['tw'], featured: true }; + +// OmniBlocks Gallery +export const galleryLoadingOB = { + name: ( + + ), + href: 'https://omniblocks.github.io/extensions', + extensionId: 'galleryOB', + iconURL: obgalleryIcon, + description: ( + + ), + tags: ['ob'], + featured: true +}; + +export const galleryMoreOB = { + name: ( + + ), + href: 'https://omniblocks.github.io/extensions', + extensionId: 'galleryOB', + iconURL: obgalleryIcon, + description: ( + + ), + tags: ['ob'], + featured: true +}; + +export const galleryErrorOB = { + name: ( + + ), + href: 'https://omniblocks.github.io/extensions', + extensionId: 'galleryOB', + iconURL: obgalleryIcon, + description: ( + + ), + tags: ['ob'], + featured: true +}; diff --git a/src/lib/libraries/extensions/javascript/javascript.png b/src/lib/libraries/extensions/javascript/javascript.png new file mode 100644 index 000000000..0983957df Binary files /dev/null and b/src/lib/libraries/extensions/javascript/javascript.png differ diff --git a/src/lib/tw-security-manager-constants.js b/src/lib/tw-security-manager-constants.js index d4b349b4c..ff614c6d7 100644 --- a/src/lib/tw-security-manager-constants.js +++ b/src/lib/tw-security-manager-constants.js @@ -1,5 +1,6 @@ const SecurityModals = { LoadExtension: 'LoadExtension', + Unsandbox: 'Unsandbox', Fetch: 'Fetch', OpenWindow: 'OpenWindow', Redirect: 'Redirect',