diff --git a/src/l10n/en.json b/src/l10n/en.json index 4347194..c3e9bcd 100644 --- a/src/l10n/en.json +++ b/src/l10n/en.json @@ -233,6 +233,17 @@ "plugins.customExtensionVersionManager.error.idMismatch": "The URL did not load the expected extension ID {expected}. Loaded: {actual}.", "plugins.customExtensionVersionManager.error.missingOpcodes": "The new version is missing blocks used by the project: {opcodes}.", "plugins.customExtensionVersionManager.error.blockTypeChanged": "The new version changes the type of blocks used by the project: {opcodes}.", + "plugins.customExtensionVersionManager.checkingCompatibility": "Checking...", + "plugins.customExtensionVersionManager.compatibilityWarningTitle": "This version may be incompatible", + "plugins.customExtensionVersionManager.compatibilityWarningDescription": "Version {version} is missing, hides, or changes {count} blocks used by this project.", + "plugins.customExtensionVersionManager.compatibilityWarningHint": "Select an item to jump to that block. You can cancel and fix the project, or switch anyway.", + "plugins.customExtensionVersionManager.incompatibleBlockLocation": "In {target} · Click to locate", + "plugins.customExtensionVersionManager.incompatibleBlockMissing": "This block is not available in the target version.", + "plugins.customExtensionVersionManager.incompatibleBlockHidden": "This block is hidden in the target version.", + "plugins.customExtensionVersionManager.incompatibleBlockTypeChanged": "Block type changes from {from} to {to}.", + "plugins.customExtensionVersionManager.cancel": "Cancel", + "plugins.customExtensionVersionManager.forceSwitch": "Switch anyway", + "plugins.customExtensionVersionManager.backToVersions": "Back to versions", "plugins.voiceCooperation.title": "Voice Cooperation", "plugins.voiceCooperation.description": "Voice cooperate with your team members!", "plugins.voiceCooperation.join": "Join Voice", @@ -420,4 +431,4 @@ "plugins.debuggerAddon.terminalLoadMode.auto": "Load automatically with project", "plugins.debuggerAddon.terminalLoadMode.onClick": "Load on icon click", "plugins.debuggerAddon.terminalLoadMode.manual": "Do not load automatically" -} \ No newline at end of file +} diff --git a/src/l10n/es.json b/src/l10n/es.json index 10a3dc6..b4843df 100644 --- a/src/l10n/es.json +++ b/src/l10n/es.json @@ -221,6 +221,17 @@ "plugins.customExtensionVersionManager.error.idMismatch": "La URL no cargó el ID de extensión esperado {expected}. Cargado: {actual}.", "plugins.customExtensionVersionManager.error.missingOpcodes": "La nueva versión no contiene bloques usados por el proyecto: {opcodes}.", "plugins.customExtensionVersionManager.error.blockTypeChanged": "La nueva versión cambia el tipo de los bloques usados por el proyecto: {opcodes}.", + "plugins.customExtensionVersionManager.checkingCompatibility": "Comprobando...", + "plugins.customExtensionVersionManager.compatibilityWarningTitle": "Esta versión puede ser incompatible", + "plugins.customExtensionVersionManager.compatibilityWarningDescription": "La versión {version} no incluye, oculta o cambia {count} bloques usados por este proyecto.", + "plugins.customExtensionVersionManager.compatibilityWarningHint": "Selecciona un elemento para ir a ese bloque. Puedes cancelar y corregir el proyecto, o cambiar de todos modos.", + "plugins.customExtensionVersionManager.incompatibleBlockLocation": "En {target} · Haz clic para localizar", + "plugins.customExtensionVersionManager.incompatibleBlockMissing": "Este bloque no está disponible en la versión de destino.", + "plugins.customExtensionVersionManager.incompatibleBlockHidden": "Este bloque está oculto en la versión de destino.", + "plugins.customExtensionVersionManager.incompatibleBlockTypeChanged": "El tipo de bloque cambia de {from} a {to}.", + "plugins.customExtensionVersionManager.cancel": "Cancelar", + "plugins.customExtensionVersionManager.forceSwitch": "Cambiar de todos modos", + "plugins.customExtensionVersionManager.backToVersions": "Volver a las versiones", "plugins.voiceCooperation.title": "Cooperación de voz", "plugins.voiceCooperation.description": "¡Cooperar por voz con los miembros de tu equipo!", "plugins.voiceCooperation.join": "Unirse a voz", @@ -357,4 +368,4 @@ "plugins.debuggerAddon.terminalLoadMode.auto": "Cargar automáticamente con el proyecto", "plugins.debuggerAddon.terminalLoadMode.onClick": "Cargar al hacer clic en el icono", "plugins.debuggerAddon.terminalLoadMode.manual": "No cargar automáticamente" -} \ No newline at end of file +} diff --git a/src/l10n/ms.json b/src/l10n/ms.json index a726be0..abd75f0 100644 --- a/src/l10n/ms.json +++ b/src/l10n/ms.json @@ -221,6 +221,17 @@ "plugins.customExtensionVersionManager.error.idMismatch": "URL tersebut tidak memuatkan ID blok tambahan yang dijangka, {expected}. Dimuatkan: {actual}.", "plugins.customExtensionVersionManager.error.missingOpcodes": "Versi baharu tidak mempunyai blok yang digunakan oleh projek: {opcodes}.", "plugins.customExtensionVersionManager.error.blockTypeChanged": "Versi baharu mengubah jenis blok yang digunakan oleh projek: {opcodes}.", + "plugins.customExtensionVersionManager.checkingCompatibility": "Menyemak...", + "plugins.customExtensionVersionManager.compatibilityWarningTitle": "Versi ini mungkin tidak serasi", + "plugins.customExtensionVersionManager.compatibilityWarningDescription": "Versi {version} tiada, menyembunyikan atau mengubah {count} blok yang digunakan oleh projek ini.", + "plugins.customExtensionVersionManager.compatibilityWarningHint": "Pilih item untuk pergi ke blok tersebut. Anda boleh batal dan membaiki projek, atau tetap menukar.", + "plugins.customExtensionVersionManager.incompatibleBlockLocation": "Dalam {target} · Klik untuk mencari", + "plugins.customExtensionVersionManager.incompatibleBlockMissing": "Blok ini tidak tersedia dalam versi sasaran.", + "plugins.customExtensionVersionManager.incompatibleBlockHidden": "Blok ini disembunyikan dalam versi sasaran.", + "plugins.customExtensionVersionManager.incompatibleBlockTypeChanged": "Jenis blok berubah daripada {from} kepada {to}.", + "plugins.customExtensionVersionManager.cancel": "Batal", + "plugins.customExtensionVersionManager.forceSwitch": "Tetap tukar", + "plugins.customExtensionVersionManager.backToVersions": "Kembali ke senarai versi", "plugins.voiceCooperation.title": "Kerjasama Suara", "plugins.voiceCooperation.description": "Bekerjasama secara suara dengan ahli pasukan anda!", "plugins.voiceCooperation.join": "Sertai Suara", @@ -360,4 +371,4 @@ "plugins.debuggerAddon.terminalLoadMode.auto": "Muat secara automatik dengan projek", "plugins.debuggerAddon.terminalLoadMode.onClick": "Muat apabila ikon diklik", "plugins.debuggerAddon.terminalLoadMode.manual": "Jangan muat secara automatik" -} \ No newline at end of file +} diff --git a/src/l10n/ru.json b/src/l10n/ru.json index 32ebe0e..1d631a5 100644 --- a/src/l10n/ru.json +++ b/src/l10n/ru.json @@ -221,6 +221,17 @@ "plugins.customExtensionVersionManager.error.idMismatch": "URL не загрузил ожидаемое расширение с ID {expected}. Загружено: {actual}.", "plugins.customExtensionVersionManager.error.missingOpcodes": "В новой версии отсутствуют блоки, используемые в проекте: {opcodes}.", "plugins.customExtensionVersionManager.error.blockTypeChanged": "В новой версии изменён тип блоков, используемых в проекте: {opcodes}.", + "plugins.customExtensionVersionManager.checkingCompatibility": "Проверка...", + "plugins.customExtensionVersionManager.compatibilityWarningTitle": "Эта версия может быть несовместима", + "plugins.customExtensionVersionManager.compatibilityWarningDescription": "В версии {version} отсутствуют, скрыты или изменены {count} блоков, используемых этим проектом.", + "plugins.customExtensionVersionManager.compatibilityWarningHint": "Нажмите на элемент, чтобы перейти к блоку. Можно отменить действие и исправить проект либо всё равно переключить версию.", + "plugins.customExtensionVersionManager.incompatibleBlockLocation": "Объект: {target} · Нажмите, чтобы перейти", + "plugins.customExtensionVersionManager.incompatibleBlockMissing": "Этот блок недоступен в целевой версии.", + "plugins.customExtensionVersionManager.incompatibleBlockHidden": "Этот блок скрыт в целевой версии.", + "plugins.customExtensionVersionManager.incompatibleBlockTypeChanged": "Тип блока меняется с {from} на {to}.", + "plugins.customExtensionVersionManager.cancel": "Отмена", + "plugins.customExtensionVersionManager.forceSwitch": "Всё равно переключить", + "plugins.customExtensionVersionManager.backToVersions": "Вернуться к списку версий", "plugins.voiceCooperation.title": "Голосовое взаимодействие", "plugins.voiceCooperation.description": "Голосовое взаимодействие с членами вашей команды!", "plugins.voiceCooperation.join": "Присоединиться к голосу", @@ -357,4 +368,4 @@ "plugins.editorOptimization.enableFullscreenDesc": "Скрывает область блоков при полноэкранном режиме сцены, уменьшая ненужную нагрузку на рендеринг области блоков.", "plugins.editorOptimization.enablePixiLabel": "[Экспериментально] Включить ускоренный рендеринг Pixi", "plugins.editorOptimization.enablePixiDesc": "Использует PixiJS для рендеринга области блоков в виде текстуры, значительно снижая количество узлов DOM и повышая плавность перетаскивания/масштабирования. Текстура Pixi обеспечивает ограниченную интерактивность; двойной щелчок для восстановления нативного отображения DOM." -} \ No newline at end of file +} diff --git a/src/l10n/uk.json b/src/l10n/uk.json index 70b38d4..dba9a61 100644 --- a/src/l10n/uk.json +++ b/src/l10n/uk.json @@ -219,6 +219,17 @@ "plugins.customExtensionVersionManager.error.idMismatch": "URL-адреса не завантажила очікуване розширення з ID {expected}. Завантажено: {actual}.", "plugins.customExtensionVersionManager.error.missingOpcodes": "У новій версії відсутні блоки, які використовує проєкт: {opcodes}.", "plugins.customExtensionVersionManager.error.blockTypeChanged": "У новій версії змінено тип блоків, які використовує проєкт: {opcodes}.", + "plugins.customExtensionVersionManager.checkingCompatibility": "Перевірка...", + "plugins.customExtensionVersionManager.compatibilityWarningTitle": "Ця версія може бути несумісною", + "plugins.customExtensionVersionManager.compatibilityWarningDescription": "У версії {version} відсутні, приховані або змінені {count} блоків, які використовує цей проєкт.", + "plugins.customExtensionVersionManager.compatibilityWarningHint": "Натисніть елемент, щоб перейти до блоку. Можна скасувати дію й виправити проєкт або все одно перемкнути версію.", + "plugins.customExtensionVersionManager.incompatibleBlockLocation": "Об’єкт: {target} · Натисніть, щоб перейти", + "plugins.customExtensionVersionManager.incompatibleBlockMissing": "Цей блок недоступний у цільовій версії.", + "plugins.customExtensionVersionManager.incompatibleBlockHidden": "Цей блок приховано в цільовій версії.", + "plugins.customExtensionVersionManager.incompatibleBlockTypeChanged": "Тип блока змінюється з {from} на {to}.", + "plugins.customExtensionVersionManager.cancel": "Скасувати", + "plugins.customExtensionVersionManager.forceSwitch": "Усе одно перемкнути", + "plugins.customExtensionVersionManager.backToVersions": "Повернутися до списку версій", "plugins.voiceCooperation.title": "Голосова співпраця", "plugins.voiceCooperation.description": "Співпрацюйте в голосі з членами команди!", "plugins.voiceCooperation.join": "Приєднатися до голосу", @@ -355,4 +366,4 @@ "plugins.debuggerAddon.terminalLoadMode.auto": "Завантажувати автоматично з проєктом", "plugins.debuggerAddon.terminalLoadMode.onClick": "Завантажувати по кліку на іконку", "plugins.debuggerAddon.terminalLoadMode.manual": "Не завантажувати автоматично" -} \ No newline at end of file +} diff --git a/src/l10n/zh-cn.json b/src/l10n/zh-cn.json index a545924..a6308b9 100644 --- a/src/l10n/zh-cn.json +++ b/src/l10n/zh-cn.json @@ -234,6 +234,17 @@ "plugins.customExtensionVersionManager.error.idMismatch": "该 URL 没有加载预期扩展 ID {expected},实际加载:{actual}。", "plugins.customExtensionVersionManager.error.missingOpcodes": "新版本缺少工程正在使用的积木:{opcodes}。", "plugins.customExtensionVersionManager.error.blockTypeChanged": "新版本改变了工程正在使用的积木类型:{opcodes}。", + "plugins.customExtensionVersionManager.checkingCompatibility": "检查中...", + "plugins.customExtensionVersionManager.compatibilityWarningTitle": "此版本可能不兼容", + "plugins.customExtensionVersionManager.compatibilityWarningDescription": "版本 {version} 缺少、隐藏或改变了当前工程正在使用的 {count} 个积木。", + "plugins.customExtensionVersionManager.compatibilityWarningHint": "点击条目可跳转到对应积木。你可以取消并修改工程,也可以直接强制切换。", + "plugins.customExtensionVersionManager.incompatibleBlockLocation": "位于 {target} · 点击定位", + "plugins.customExtensionVersionManager.incompatibleBlockMissing": "目标版本中不存在此积木。", + "plugins.customExtensionVersionManager.incompatibleBlockHidden": "目标版本中此积木已被隐藏。", + "plugins.customExtensionVersionManager.incompatibleBlockTypeChanged": "积木类型由 {from} 变为 {to}。", + "plugins.customExtensionVersionManager.cancel": "取消", + "plugins.customExtensionVersionManager.forceSwitch": "强制切换", + "plugins.customExtensionVersionManager.backToVersions": "返回版本列表", "plugins.voiceCooperation.title": "语音协作", "plugins.voiceCooperation.description": "与团队成员一起语音协作!", "plugins.voiceCooperation.join": "加入语音", @@ -420,4 +431,4 @@ "plugins.editorOptimization.enableFullscreenDesc": "在舞台全屏时隐藏积木区,减少不必要的积木区渲染开销。", "plugins.editorOptimization.enablePixiLabel": "[实验] 启用 Pixi 加速渲染", "plugins.editorOptimization.enablePixiDesc": "使用 PixiJS 将积木区渲染为纹理,大幅降低 DOM 节点数并提升拖拽/缩放流畅度。Pixi纹理提供受限的交互功能,通过双击以恢复原生DOM显示。" -} \ No newline at end of file +} diff --git a/src/plugins/custom-extension-version-manager/compatibility.ts b/src/plugins/custom-extension-version-manager/compatibility.ts new file mode 100644 index 0000000..6a60e7e --- /dev/null +++ b/src/plugins/custom-extension-version-manager/compatibility.ts @@ -0,0 +1,53 @@ +import { ExtensionBlockDefinition, ExtensionMetadata, IncompatibleBlockUsage, UsedExtensionBlock } from "./types"; + +const DEFAULT_BLOCK_TYPE = "command"; + +const getBlockDefinitions = (metadata: ExtensionMetadata): Map => { + const definitions = new Map(); + (metadata.blocks || []).forEach((block) => { + if (typeof block === "string" || !block.opcode) return; + definitions.set(block.opcode, block); + }); + return definitions; +}; + +const normalizeBlockType = (blockType: string | undefined) => blockType || DEFAULT_BLOCK_TYPE; + +export const compareUsedExtensionBlocks = ( + usedBlocks: UsedExtensionBlock[], + targetMetadata: ExtensionMetadata, +): IncompatibleBlockUsage[] => { + const targetDefinitions = getBlockDefinitions(targetMetadata); + + return usedBlocks.reduce((issues, usedBlock) => { + const targetDefinition = targetDefinitions.get(usedBlock.opcode); + if (!targetDefinition) { + issues.push({ + ...usedBlock, + reason: "missing", + }); + return issues; + } + + if (targetDefinition.hideFromPalette) { + issues.push({ + ...usedBlock, + reason: "hidden", + targetBlockType: normalizeBlockType(targetDefinition.blockType), + }); + return issues; + } + + if ( + usedBlock.blockType && + normalizeBlockType(usedBlock.blockType) !== normalizeBlockType(targetDefinition.blockType) + ) { + issues.push({ + ...usedBlock, + reason: "blockTypeChanged", + targetBlockType: normalizeBlockType(targetDefinition.blockType), + }); + } + return issues; + }, []); +}; diff --git a/src/plugins/custom-extension-version-manager/index.tsx b/src/plugins/custom-extension-version-manager/index.tsx index 90d156c..533c83b 100644 --- a/src/plugins/custom-extension-version-manager/index.tsx +++ b/src/plugins/custom-extension-version-manager/index.tsx @@ -5,9 +5,20 @@ import ExpansionBox, { ExpansionRect } from "components/ExpansionBox"; import Tooltip from "components/Tooltip"; import useStorageInfo from "hooks/useStorageInfo"; import ExtensionVersionIcon from "assets/icon--extension-version.svg"; +import { scrollBlockIntoView } from "utils/block-helper"; import { fetchMarketplaceExtension, getMarketplacePageUrl, MarketplaceRequestError } from "./marketplace"; -import { getLoadedCustomExtensions, switchCustomExtensionVersion } from "./runtime"; -import { ExtensionVersion, ManagedCustomExtension, MarketplaceExtensionDetail } from "./types"; +import { + getLoadedCustomExtensions, + inspectCustomExtensionVersionCompatibility, + switchCustomExtensionVersion, +} from "./runtime"; +import { + ExtensionCompatibilityReport, + ExtensionVersion, + IncompatibleBlockUsage, + ManagedCustomExtension, + MarketplaceExtensionDetail, +} from "./types"; import styles from "./styles.less"; const DEFAULT_CONTAINER_INFO: ExpansionRect = { @@ -25,6 +36,11 @@ const formatReleasedAt = (value: string | number | undefined, locale: string) => return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date); }; +type CompatibilityWarningState = { + version: ExtensionVersion; + report: ExtensionCompatibilityReport; +}; + const CustomExtensionVersionManager: React.FC = ({ vm, workspace, intl, msg, registerSettings }) => { const [visible, setVisible] = React.useState(false); const [containerInfo, setContainerInfo] = useStorageInfo( @@ -36,11 +52,15 @@ const CustomExtensionVersionManager: React.FC = ({ vm, workspace, const [marketplaceDetail, setMarketplaceDetail] = React.useState(null); const [marketplaceError, setMarketplaceError] = React.useState(""); const [loadingVersions, setLoadingVersions] = React.useState(false); + const [checkingVersionId, setCheckingVersionId] = React.useState(""); const [switchingVersionId, setSwitchingVersionId] = React.useState(""); + const [compatibilityWarning, setCompatibilityWarning] = React.useState(null); + const [compatibilityModalVisible, setCompatibilityModalVisible] = React.useState(false); const rootRef = React.useRef(null); const containerInfoRef = React.useRef(containerInfo); const marketplaceRequestRef = React.useRef(null); + const pendingBlockRef = React.useRef(null); const selectedExtension = extensions.find((extension) => extension.id === selectedExtensionId) || null; const versions = marketplaceDetail?.eid === selectedExtensionId ? marketplaceDetail.versions : []; @@ -138,24 +158,110 @@ const CustomExtensionVersionManager: React.FC = ({ vm, workspace, [formatMarketplaceError], ); - const switchVersion = React.useCallback( - async (version: ExtensionVersion) => { - if (!selectedExtension || switchingVersionId) return; - setSwitchingVersionId(version.id); + const performVersionSwitch = React.useCallback( + async (version: ExtensionVersion, extensionId: string, force = false) => { try { - await switchCustomExtensionVersion(vm, workspace, selectedExtension.id, version.url); + await switchCustomExtensionVersion(vm, workspace, extensionId, version.url, force); refreshExtensions(); toast.success( intl.formatMessage({ id: "plugins.customExtensionVersionManager.switchSuccess" }, { version: version.label }), ); + return true; } catch (error) { refreshExtensions(); + setTimeout(refreshExtensions, 0); + toast.error(formatSwitchError(error), { duration: 7000 }); + return false; + } + }, + [formatSwitchError, intl, refreshExtensions, vm, workspace], + ); + + const switchVersion = React.useCallback( + async (version: ExtensionVersion) => { + if (!selectedExtension || checkingVersionId || switchingVersionId) return; + const extensionId = selectedExtension.id; + setCheckingVersionId(version.id); + try { + const report = await inspectCustomExtensionVersionCompatibility(vm, extensionId, version.url); + refreshExtensions(); + setTimeout(refreshExtensions, 0); + if (report.issues.length > 0) { + setCompatibilityWarning({ version, report }); + setCompatibilityModalVisible(true); + return; + } + + setCheckingVersionId(""); + setSwitchingVersionId(version.id); + await performVersionSwitch(version, extensionId); + } catch (error) { + refreshExtensions(); + setTimeout(refreshExtensions, 0); toast.error(formatSwitchError(error), { duration: 7000 }); } finally { + setCheckingVersionId(""); setSwitchingVersionId(""); } }, - [formatSwitchError, intl, refreshExtensions, selectedExtension, switchingVersionId, vm, workspace], + [ + checkingVersionId, + formatSwitchError, + performVersionSwitch, + refreshExtensions, + selectedExtension, + switchingVersionId, + vm, + ], + ); + + const forceSwitchVersion = React.useCallback(async () => { + if (!compatibilityWarning || switchingVersionId) return; + const { version, report } = compatibilityWarning; + setCompatibilityModalVisible(false); + setCheckingVersionId(""); + setSwitchingVersionId(version.id); + try { + const switched = await performVersionSwitch(version, report.extensionId, true); + if (switched) setCompatibilityWarning(null); + } finally { + setSwitchingVersionId(""); + } + }, [compatibilityWarning, performVersionSwitch, switchingVersionId]); + + const revealPendingBlock = React.useCallback(() => { + if (!pendingBlockRef.current) return; + let attempts = 0; + const reveal = () => { + const pendingBlock = pendingBlockRef.current; + if (!pendingBlock) return; + const block = workspace.blockDB_[pendingBlock.blockId] || workspace.getBlockById(pendingBlock.blockId); + if (block) { + pendingBlockRef.current = null; + scrollBlockIntoView(block, workspace); + return; + } + attempts += 1; + if (attempts < 60) { + requestAnimationFrame(reveal); + } else { + pendingBlockRef.current = null; + } + }; + requestAnimationFrame(reveal); + }, [workspace]); + + const jumpToIncompatibleBlock = React.useCallback( + (issue: IncompatibleBlockUsage) => { + setCompatibilityModalVisible(false); + pendingBlockRef.current = issue; + if (vm.editingTarget?.id === issue.targetId) { + revealPendingBlock(); + } else { + vm.setEditingTarget(issue.targetId); + } + }, + [revealPendingBlock, vm], ); const getContainerPosition = React.useCallback(() => { @@ -214,6 +320,14 @@ const CustomExtensionVersionManager: React.FC = ({ vm, workspace, }; }, [refreshExtensions, vm]); + React.useEffect(() => { + const handleTargetUpdate = () => revealPendingBlock(); + vm.on("targetsUpdate", handleTargetUpdate); + return () => { + vm.off("targetsUpdate", handleTargetUpdate); + }; + }, [revealPendingBlock, vm]); + React.useEffect(() => { if (!visible) return; void loadMarketplaceVersions(selectedExtensionId); @@ -247,7 +361,11 @@ const CustomExtensionVersionManager: React.FC = ({ vm, workspace, minWidth={400} minHeight={460} borderRadius={8} - onClose={() => setVisible(false)} + onClose={() => { + setVisible(false); + setCompatibilityWarning(null); + setCompatibilityModalVisible(false); + }} onSizeChange={handleSizeChange} containerInfo={containerInfo} > @@ -261,7 +379,11 @@ const CustomExtensionVersionManager: React.FC = ({ vm, workspace, id="custom-extension-version-manager-extension" className={styles.select} value={selectedExtensionId} - onChange={(event) => setSelectedExtensionId(event.target.value)} + onChange={(event) => { + setSelectedExtensionId(event.target.value); + setCompatibilityWarning(null); + setCompatibilityModalVisible(false); + }} disabled={extensions.length === 0} > {extensions.length === 0 ? ( @@ -313,74 +435,148 @@ const CustomExtensionVersionManager: React.FC = ({ vm, workspace, -
- {msg("plugins.customExtensionVersionManager.versionList")} - {versions.length} -
-
- {loadingVersions ? ( -
{msg("plugins.customExtensionVersionManager.loadingVersions")}
- ) : marketplaceError ? ( -
- {marketplaceError} + {compatibilityWarning ? ( + <> +
+ {msg("plugins.customExtensionVersionManager.compatibilityWarningTitle")}
- ) : versions.length === 0 ? ( -
{msg("plugins.customExtensionVersionManager.noVersions")}
- ) : ( - versions.map((version) => { - const isCurrent = version.url === selectedExtension.currentUrl; - const isSwitching = switchingVersionId === version.id; - const releasedAt = formatReleasedAt(version.releasedAt, intl.locale); - return ( -
-
-
- {version.label} - {isCurrent && ( - - {msg("plugins.customExtensionVersionManager.current")} - - )} - {version.releaseTags.map((tag) => ( - - {tag} - - ))} -
- - {version.url} - - {releasedAt && } - {version.changelog && ( -

- {version.changelog} -

+

+ {intl.formatMessage( + { id: "plugins.customExtensionVersionManager.compatibilityWarningDescription" }, + { + version: compatibilityWarning.version.label, + count: compatibilityWarning.report.issues.length, + }, + )} +

+
+ {compatibilityWarning.report.issues.map((issue, index) => ( +
-
- -
-
- ); - }) - )} -
+ + + {issue.reason === "missing" + ? msg("plugins.customExtensionVersionManager.incompatibleBlockMissing") + : issue.reason === "hidden" + ? msg("plugins.customExtensionVersionManager.incompatibleBlockHidden") + : intl.formatMessage( + { id: "plugins.customExtensionVersionManager.incompatibleBlockTypeChanged" }, + { + from: issue.blockType || msg("plugins.customExtensionVersionManager.unknown"), + to: + issue.targetBlockType || msg("plugins.customExtensionVersionManager.unknown"), + }, + )} + + + ))} +
+
+ +
+ + ) : ( + <> +
+ {msg("plugins.customExtensionVersionManager.versionList")} + {versions.length} +
+
+ {loadingVersions ? ( +
+ {msg("plugins.customExtensionVersionManager.loadingVersions")} +
+ ) : marketplaceError ? ( +
+ {marketplaceError} + +
+ ) : versions.length === 0 ? ( +
{msg("plugins.customExtensionVersionManager.noVersions")}
+ ) : ( + versions.map((version) => { + const isCurrent = version.url === selectedExtension.currentUrl; + const isChecking = checkingVersionId === version.id; + const isSwitching = switchingVersionId === version.id; + const releasedAt = formatReleasedAt(version.releasedAt, intl.locale); + return ( +
+
+
+ {version.label} + {isCurrent && ( + + {msg("plugins.customExtensionVersionManager.current")} + + )} + {version.releaseTags.map((tag) => ( + + {tag} + + ))} +
+ + {version.url} + + {releasedAt && } + {version.changelog && ( +

+ {version.changelog} +

+ )} +
+
+ +
+
+ ); + }) + )} +
+ + )} ) : (
{msg("plugins.customExtensionVersionManager.emptyHint")}
@@ -389,6 +585,92 @@ const CustomExtensionVersionManager: React.FC = ({ vm, workspace, , document.body, )} + + {compatibilityWarning && + compatibilityModalVisible && + ReactDOM.createPortal( +
setCompatibilityModalVisible(false)} + role="presentation" + > +
event.stopPropagation()} + > +
+ ! +
+

+ {msg("plugins.customExtensionVersionManager.compatibilityWarningTitle")} +

+

+ {intl.formatMessage( + { id: "plugins.customExtensionVersionManager.compatibilityWarningDescription" }, + { + version: compatibilityWarning.version.label, + count: compatibilityWarning.report.issues.length, + }, + )} +

+
+
+

+ {msg("plugins.customExtensionVersionManager.compatibilityWarningHint")} +

+
+ {compatibilityWarning.report.issues.map((issue, index) => ( + + ))} +
+
+ + +
+
+
, + document.body, + )} , toolbarHost, ); diff --git a/src/plugins/custom-extension-version-manager/runtime.ts b/src/plugins/custom-extension-version-manager/runtime.ts index 05f829e..d78c66b 100644 --- a/src/plugins/custom-extension-version-manager/runtime.ts +++ b/src/plugins/custom-extension-version-manager/runtime.ts @@ -1,13 +1,38 @@ -import { ManagedCustomExtension } from "./types"; +import { compareUsedExtensionBlocks } from "./compatibility"; +import { + ExtensionBlockDefinition, + ExtensionCompatibilityReport, + ExtensionMetadata, + ManagedCustomExtension, + UsedExtensionBlock, +} from "./types"; type CustomExtensionInfo = Extension & { replaceable?: boolean; url?: string; }; +type ExternalExtensionInstance = { + getInfo(): ExtensionMetadata | Promise; +}; + +type ExternalExtensionConstructor = new (runtime: Scratch.Runtime) => ExternalExtensionInstance; + type VersionManagerExtensionManager = Scratch.ExtensionManager & { _customExtensionInfo: Record; getLoadedExtensionURLs(): Record; + registerExtension(extensionId: string, extension: unknown, shouldReplace?: boolean): string | undefined; +}; + +type RuntimeBlockCategory = { + id: string; + blocks?: Array<{ + info?: ExtensionBlockDefinition; + }>; +}; + +type RuntimeWithBlockInfo = Scratch.Runtime & { + _blockInfo?: RuntimeBlockCategory[]; }; const normalizeLoadResult = (result: unknown): string[] => { @@ -28,6 +53,113 @@ const normalizeLoadResult = (result: unknown): string[] => { return flattened; }; +const createExtensionIdMismatchError = (extensionId: string, loadedIds: string[]) => { + const error = new Error("EXTENSION_ID_MISMATCH") as Error & { + expectedId?: string; + loadedIds?: string[]; + }; + error.expectedId = extensionId; + error.loadedIds = loadedIds; + return error; +}; + +const getBlockLabel = (text: unknown, fallback: string) => { + if (typeof text === "string" && text.trim()) return text.trim(); + if (text && typeof text === "object") { + const descriptor = text as { default?: unknown; defaultMessage?: unknown; id?: unknown }; + if (typeof descriptor.default === "string" && descriptor.default.trim()) return descriptor.default.trim(); + if (typeof descriptor.defaultMessage === "string" && descriptor.defaultMessage.trim()) { + return descriptor.defaultMessage.trim(); + } + if (typeof descriptor.id === "string" && descriptor.id.trim()) return descriptor.id.trim(); + } + return fallback; +}; + +const collectUsedExtensionBlocks = (vm: PluginContext["vm"], extensionId: string): UsedExtensionBlock[] => { + const runtime = vm.runtime as RuntimeWithBlockInfo; + const category = runtime._blockInfo?.find((item) => item.id === extensionId); + const currentDefinitions = new Map(); + (category?.blocks || []).forEach((block) => { + if (block.info?.opcode) currentDefinitions.set(block.info.opcode, block.info); + }); + + const prefix = `${extensionId}_`; + const seen = new Set(); + const usedBlocks: UsedExtensionBlock[] = []; + + runtime.targets.forEach((target) => { + if (!target.isStage && !target.isOriginal) return; + Object.values(target.blocks._blocks).forEach((block) => { + if (!block.opcode.startsWith(prefix)) return; + const key = `${target.id}:${block.id}`; + if (seen.has(key)) return; + seen.add(key); + + const opcode = block.opcode.slice(prefix.length); + const mutationInfo = block.mutation?.blockInfo as ExtensionBlockDefinition | undefined; + const currentDefinition = mutationInfo || currentDefinitions.get(opcode); + usedBlocks.push({ + blockId: block.id, + targetId: target.id, + targetName: target.getName?.() || target.sprite?.name || target.id, + opcode, + label: getBlockLabel(currentDefinition?.text, opcode), + blockType: currentDefinition?.blockType, + }); + }); + }); + + return usedBlocks; +}; + +const getExternalExtensionMetadata = async ( + manager: VersionManagerExtensionManager, + vm: PluginContext["vm"], + extensionId: string, +) => { + const extension = (await manager.getExternalExtensionConstructor(extensionId)) as + | ExternalExtensionConstructor + | ExternalExtensionInstance; + const instance = + typeof extension === "function" ? new (extension as ExternalExtensionConstructor)(vm.runtime) : extension; + if (!instance || typeof instance.getInfo !== "function") { + throw new Error(`Extension ${extensionId} does not provide getInfo().`); + } + return Promise.resolve(instance.getInfo()); +}; + +const restorePreviousLibraryEntry = async ( + manager: VersionManagerExtensionManager, + extensionId: string, + previousInfo: CustomExtensionInfo | undefined, + previousUrl: string, + previousExtension: unknown, +) => { + const previousConstructor = + typeof previousExtension === "function" + ? previousExtension + : previousExtension && + typeof previousExtension === "object" && + typeof (previousExtension as { constructor?: unknown }).constructor === "function" + ? (previousExtension as { constructor: unknown }).constructor + : undefined; + + if (previousInfo && previousConstructor) { + manager.addCustomExtensionInfo( + { + ...previousInfo, + Extension: previousConstructor, + }, + previousUrl, + ); + } else if (previousUrl) { + await manager.loadExternalExtensionToLibrary(previousUrl, true, true); + } + + if (previousInfo) manager._customExtensionInfo[extensionId] = previousInfo; +}; + export const getLoadedCustomExtensions = (vm: PluginContext["vm"]): ManagedCustomExtension[] => { const manager = vm.extensionManager as VersionManagerExtensionManager; const loadedUrls = manager.getLoadedExtensionURLs?.() || {}; @@ -38,11 +170,50 @@ export const getLoadedCustomExtensions = (vm: PluginContext["vm"]): ManagedCusto .map(([extensionId, extension]) => ({ id: extensionId, name: extension.info?.name || extensionId, - currentUrl: loadedUrls[extensionId] || extension.url || wildExtensions[extensionId]?.url || "", + currentUrl: wildExtensions[extensionId]?.url || loadedUrls[extensionId] || extension.url || "", })) .sort((a, b) => a.name.localeCompare(b.name)); }; +export const inspectCustomExtensionVersionCompatibility = async ( + vm: PluginContext["vm"], + extensionId: string, + url: string, +): Promise => { + const manager = vm.extensionManager as VersionManagerExtensionManager; + const previousInfo = manager._customExtensionInfo[extensionId]; + const previousUrl = manager.getLoadedExtensionURLs?.()?.[extensionId] || previousInfo?.url || ""; + let previousExtension: unknown; + + try { + previousExtension = await manager.getExternalExtensionConstructor(extensionId); + } catch { + previousExtension = undefined; + } + + let targetMetadata: ExtensionMetadata; + let loadedIds: string[] = []; + try { + const result = await manager.loadExternalExtensionToLibrary(url, true, true); + loadedIds = Array.from(new Set([...(result.onlyAdded || []), ...(result.addedAndLoaded || [])])); + if (!loadedIds.includes(extensionId)) throw createExtensionIdMismatchError(extensionId, loadedIds); + + targetMetadata = await getExternalExtensionMetadata(manager, vm, extensionId); + if (targetMetadata.id !== extensionId) { + throw createExtensionIdMismatchError(extensionId, targetMetadata.id ? [targetMetadata.id] : loadedIds); + } + } finally { + await restorePreviousLibraryEntry(manager, extensionId, previousInfo, previousUrl, previousExtension); + } + + const usedBlocks = collectUsedExtensionBlocks(vm, extensionId); + return { + extensionId, + targetExtensionId: targetMetadata.id || extensionId, + issues: compareUsedExtensionBlocks(usedBlocks, targetMetadata), + }; +}; + const refreshWorkspace = (vm: PluginContext["vm"], workspace: PluginContext["workspace"]) => { setTimeout(() => { const flyout = workspace?.getFlyout?.(); @@ -86,11 +257,57 @@ const restorePreviousMetadata = async ( } }; +const loadWithCompatibilityChecksBypassed = async ( + vm: PluginContext["vm"], + manager: VersionManagerExtensionManager, + extensionId: string, + url: string, +) => { + const originalRegisterExtension = manager.registerExtension; + const patchedRegisterExtension: VersionManagerExtensionManager["registerExtension"] = ( + incomingExtensionId, + extension, + shouldReplace, + ) => { + if (incomingExtensionId !== extensionId || !shouldReplace || !manager.isExtensionLoaded(extensionId)) { + return originalRegisterExtension.call(manager, incomingExtensionId, extension, shouldReplace); + } + + const prefix = `${extensionId}_`; + const snapshots: Array<{ block: Scratch.BlockState; opcode: string }> = []; + vm.runtime.targets.forEach((target) => { + Object.values(target.blocks._blocks).forEach((block) => { + if (!block.opcode.startsWith(prefix)) return; + snapshots.push({ block, opcode: block.opcode }); + block.opcode = `__custom_extension_version_manager__${block.opcode}`; + }); + }); + + try { + return originalRegisterExtension.call(manager, incomingExtensionId, extension, shouldReplace); + } finally { + snapshots.forEach(({ block, opcode }) => { + block.opcode = opcode; + }); + } + }; + + manager.registerExtension = patchedRegisterExtension; + try { + return await manager.loadExtensionURL(url, true); + } finally { + if (manager.registerExtension === patchedRegisterExtension) { + manager.registerExtension = originalRegisterExtension; + } + } +}; + export const switchCustomExtensionVersion = async ( vm: PluginContext["vm"], workspace: PluginContext["workspace"], extensionId: string, url: string, + force = false, ) => { const manager = vm.extensionManager as VersionManagerExtensionManager; const previousInfo = manager._customExtensionInfo[extensionId]; @@ -98,7 +315,9 @@ export const switchCustomExtensionVersion = async ( const loadedBefore = new Set(manager._loadedExtensions.keys()); try { - const result = await manager.loadExtensionURL(url, true); + const result = force + ? await loadWithCompatibilityChecksBypassed(vm, manager, extensionId, url) + : await manager.loadExtensionURL(url, true); const loadedIds = normalizeLoadResult(result); const newlyLoadedIds = Array.from(manager._loadedExtensions.keys()).filter((id) => !loadedBefore.has(id)); const unexpectedIds = Array.from(new Set([...loadedIds, ...newlyLoadedIds])).filter( @@ -117,13 +336,7 @@ export const switchCustomExtensionVersion = async ( const resultMatches = loadedIds.length === 0 || loadedIds.includes(extensionId); if (!loadedExpectedExtension || !resultMatches || currentUrl !== url) { - const error = new Error("EXTENSION_ID_MISMATCH") as Error & { - expectedId?: string; - loadedIds?: string[]; - }; - error.expectedId = extensionId; - error.loadedIds = loadedIds; - throw error; + throw createExtensionIdMismatchError(extensionId, loadedIds); } manager.saveWildExtensionsURL(extensionId, url); diff --git a/src/plugins/custom-extension-version-manager/styles.less b/src/plugins/custom-extension-version-manager/styles.less index 89ebaf4..a4849b3 100644 --- a/src/plugins/custom-extension-version-manager/styles.less +++ b/src/plugins/custom-extension-version-manager/styles.less @@ -122,7 +122,8 @@ } .primary-button, -.secondary-button { +.secondary-button, +.danger-button { min-height: 34px; padding: 0 12px; border-radius: 7px; @@ -143,13 +144,20 @@ border-color: var(--theme-color-g300); } +.danger-button { + color: #fff; + background: #d84c4c; +} + .primary-button:hover:not(:disabled), -.secondary-button:hover:not(:disabled) { +.secondary-button:hover:not(:disabled), +.danger-button:hover:not(:disabled) { filter: brightness(0.96); } .primary-button:disabled, -.secondary-button:disabled { +.secondary-button:disabled, +.danger-button:disabled { cursor: not-allowed; opacity: 0.5; } @@ -252,6 +260,147 @@ color: var(--theme-error-color); } +.modal-backdrop { + position: fixed; + z-index: 100000; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgb(0 0 0 / 52%); + box-sizing: border-box; +} + +.compatibility-modal { + display: flex; + flex-direction: column; + width: min(620px, 100%); + max-height: min(720px, calc(100vh - 48px)); + padding: 20px; + border: 1px solid var(--theme-color-g300); + border-radius: 12px; + color: var(--theme-text-primary); + background: var(--theme-color-100, #fff); + box-shadow: 0 18px 60px rgb(0 0 0 / 28%); + box-sizing: border-box; +} + +.modal-header { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.modal-header h2, +.modal-header p { + margin: 0; +} + +.modal-header h2 { + font-size: 18px; + line-height: 1.4; +} + +.modal-header p { + margin-top: 5px; + opacity: 0.72; + font-size: 13px; + line-height: 1.5; +} + +.warning-icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 30px; + width: 30px; + height: 30px; + border-radius: 50%; + color: #fff; + background: #e39b29; + font-size: 20px; + font-weight: 700; +} + +.modal-hint { + margin: 14px 0 9px; + opacity: 0.72; + font-size: 12px; + line-height: 1.5; +} + +.issue-list { + display: flex; + flex: 1; + flex-direction: column; + gap: 7px; + min-height: 0; + padding-right: 3px; + overflow-y: auto; +} + +.issue-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px 12px; + width: 100%; + padding: 11px 12px; + border: 1px solid var(--theme-color-g300); + border-radius: 8px; + color: var(--theme-text-primary); + background: var(--theme-color-300); + text-align: left; + cursor: pointer; +} + +.issue-item:hover { + border-color: var(--gui-item-active); + background: var(--gui-item-hover); +} + +.issue-main { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.issue-main strong, +.issue-main code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.issue-main code, +.issue-location, +.issue-reason { + font-size: 12px; +} + +.issue-main code, +.issue-location { + opacity: 0.68; +} + +.issue-location { + align-self: center; + white-space: nowrap; +} + +.issue-reason { + grid-column: 1 / -1; + color: #d84c4c; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 16px; +} + @media (max-width: 640px) { .version-card { align-items: stretch; @@ -261,4 +410,13 @@ .version-actions { justify-content: flex-end; } + + .modal-backdrop { + padding: 12px; + } + + .issue-item { + display: flex; + flex-direction: column; + } } diff --git a/src/plugins/custom-extension-version-manager/types.ts b/src/plugins/custom-extension-version-manager/types.ts index ea6572a..0c8a2c9 100644 --- a/src/plugins/custom-extension-version-manager/types.ts +++ b/src/plugins/custom-extension-version-manager/types.ts @@ -18,3 +18,38 @@ export interface ManagedCustomExtension { name: string; currentUrl: string; } + +export interface ExtensionBlockDefinition { + opcode?: string; + blockType?: string; + text?: unknown; + hideFromPalette?: boolean; + isDynamic?: boolean; +} + +export interface ExtensionMetadata { + id?: string; + blocks?: Array; +} + +export interface UsedExtensionBlock { + blockId: string; + targetId: string; + targetName: string; + opcode: string; + label: string; + blockType?: string; +} + +export type BlockCompatibilityReason = "missing" | "hidden" | "blockTypeChanged"; + +export interface IncompatibleBlockUsage extends UsedExtensionBlock { + reason: BlockCompatibilityReason; + targetBlockType?: string; +} + +export interface ExtensionCompatibilityReport { + extensionId: string; + targetExtensionId: string; + issues: IncompatibleBlockUsage[]; +}