From a1f354fd3d76ae07095d38e6ad14c60837c689b9 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Tue, 26 Nov 2024 14:14:08 +0800 Subject: [PATCH] :sparkles: feat: add new trap method Signed-off-by: SimonShiki --- locales/en.json | 2 + locales/zh-cn.json | 2 + src/main/index.ts | 109 ++++++++++++++++++++++++++++++++++++++--- src/main/trap/redux.ts | 80 +++++++++++++++++++++++++++++- 4 files changed, 185 insertions(+), 8 deletions(-) diff --git a/locales/en.json b/locales/en.json index 347d68e..a07b5d8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -43,6 +43,8 @@ "eureka.modernBlocklyDetected": "Modern blockly detected", "eureka.gettingRedux": "Getting Redux...", "eureka.reduxReady": "Redux is ready.", + "eureka.blocksReady": "ScratchBlocks is ready.", + "eureka.lateLoading": "Eureka loads too late, trying to get Redux from DOM..", "eureka.failedToGetRedux": "Failed to get Redux.", "eureka.settings.polyfillGlobalInstances": "Expose Scratch internal instances globally", "eureka.redirectingDeclared": "Redirecting declared extension {extensionURL}", diff --git a/locales/zh-cn.json b/locales/zh-cn.json index f58f958..4969d71 100644 --- a/locales/zh-cn.json +++ b/locales/zh-cn.json @@ -43,6 +43,8 @@ "eureka.modernBlocklyDetected": "检测到现代 Blockly", "eureka.gettingRedux": "正在获取 Redux...", "eureka.reduxReady": "Redux 已预备。", + "eureka.blocksReady": "ScratchBlocks 已预备。", + "eureka.lateLoading": "Eureka 加载过晚, 尝试从 DOM 中获取 ReduxStore..", "eureka.failedToGetRedux": "获取 Redux 失败", "eureka.settings.polyfillGlobalInstances": "全局暴露 Scratch 内部实例" } \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index 4ba3635..02556da 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,7 +9,7 @@ import formatMessage from 'format-message'; import { eureka } from './ctx'; import { applyPatchesForVM, applyPatchesForBlocks } from './patches/applier'; import { setLocale } from './util/l10n'; -import { getRedux } from './trap/redux'; +import { getRedux, getReduxStoreFromDOM } from './trap/redux'; import './dashboard/app'; log.info( @@ -21,11 +21,15 @@ log.info( const settings = settingsAgent.getSettings(); -// Set traps -(async () => { +// Mutex for traps +let vmTrapped = false; + +// First trap - Hijack Function.prototype.bind +const trapViaBind = async () => { if (settings.trap.vm) { try { const vm = eureka.vm = await getVMInstance().then(vm => { + vmTrapped = true; if (settings.trap.blocks) { getScratchBlocksInstance(vm).then(blocks => { eureka.blocks = blocks; @@ -69,6 +73,8 @@ const settings = settingsAgent.getSettings(); applyPatchesForVM(vm, eureka); } } catch (e) { + if (vmTrapped) return; + log.error( formatMessage({ id: 'eureka.failedToGetVM', @@ -77,9 +83,10 @@ const settings = settingsAgent.getSettings(); , '\n', e); } } -})(); +}; -(async () => { +// Trap for Redux +const trapRedux = async () => { if (settings.trap.redux) { try { log.info( @@ -122,8 +129,98 @@ const settings = settingsAgent.getSettings(); , '\n', e); } } -})(); +}; + +// Second trap - Using React internal Redux store +const trapViaReduxStore = async () => { + if (vmTrapped) return; + try { + const store = getReduxStoreFromDOM(); + const vm = store?.getState()?.scratchGui?.vm; + if (vm) { + log.info(formatMessage({ + id: 'eureka.trap.vm.detected', + default: 'VM detected!' + })); + vmTrapped = true; + eureka.vm = vm; + if (settings.trap.blocks) { + getScratchBlocksInstance(vm).then(blocks => { + eureka.blocks = blocks; + log.info( + formatMessage({ + id: 'eureka.blocksReady', + default: 'ScratchBlocks is ready.' + }) + ); + if (settings.behavior.polyfillGlobalInstances && typeof globalThis.ScratchBlocks === 'undefined') { + globalThis.ScratchBlocks = eureka.blocks; + } + + if (!settings.behavior.headless) { + applyPatchesForBlocks(eureka.blocks); + } + }).catch(e => { + log.error( + formatMessage({ + id: 'eureka.failedToGetBlocks', + default: 'Failed to get ScratchBlocks.' + }) + , '\n', e); + }); + } + + if (settings.behavior.polyfillGlobalInstances && typeof globalThis.vm === 'undefined') { + globalThis.vm = vm; + } + setLocale(vm.getLocale()); + + if (settings.behavior.headless) { + log.warn( + formatMessage({ + id: 'eureka.headlessTips', + default: 'Headless mode on, stop apply patches.' + }) + ); + } else { + applyPatchesForVM(vm, eureka); + } + + if (settings.behavior.polyfillGlobalInstances && typeof globalThis.ReduxStore === 'undefined') { + globalThis.ReduxStore = store; + } + } + } catch (e) { + log.error( + formatMessage({ + id: 'eureka.failedToGetVM', + default: 'Failed to get VM.' + }) + , '\n', e); + } +}; +if (document.readyState !== 'complete') { + // Run both traps with race condition + trapViaBind(); + trapRedux(); + + // Second trap with 1s timeout, Since it's expensive + setTimeout(() => { + if (!vmTrapped) { + trapViaReduxStore(); + } + }, 1000); +} else { + log.warn( + formatMessage({ + id: 'eureka.loadingLate', + default: 'Eureka loads too late, trying to get Redux from DOM..' + }) + ); + // Only try Redux store trap for complete states + trapViaReduxStore(); +} if (settings.behavior.exposeCtx) { globalThis.eureka = eureka; diff --git a/src/main/trap/redux.ts b/src/main/trap/redux.ts index fe0b6c2..b2a0b33 100644 --- a/src/main/trap/redux.ts +++ b/src/main/trap/redux.ts @@ -2,8 +2,8 @@ import log from '../util/console'; export interface EuRedux { target: EventTarget; - state: any; - dispatch(action: unknown): unknown; + state: DucktypedState; + dispatch(action: DucktypedAction): unknown; } interface MiddlewareAPI { @@ -11,8 +11,37 @@ interface MiddlewareAPI { dispatch: (action: A) => void; } +interface DucktypedAction { + type: string; + [key: string]: unknown; +} + +interface DucktypedState { + scratchGui: DucktypedGUIState; + scratchPaint: DucktypedPaintState; + locales: DucktypedLocalesState; + [key: string]: unknown; +} + +interface DucktypedPaintState { + [key: string]: unknown; +} + +interface DucktypedLocalesState { + locale: string; + messages: Record; + messagesByLocale: Record>; +} + +interface DucktypedGUIState { + vm: DucktypedVM; + [key: string]: unknown +} + type Middleware = (api: MiddlewareAPI) => (next: (action: A) => void) => (action: A) => void; +type ScratchReduxStore = MiddlewareAPI; + class ReDucks { static compose (...composeArgs: ((arg: S) => S)[]): (arg: S) => S { if (composeArgs.length === 0) return (args: S) => args; @@ -105,3 +134,50 @@ export function getRedux (): Promise { } }); } + +/** + * Get redux store instance from DOM, this operation may expensive and rely on react's implementation. + * We only use it when page has been loaded and we cannot trap VM. + * + * Reference: https://pablo.gg/en/blog/coding/how-to-get-the-redux-state-from-a-react-18-production-build-via-the-browsers-console/ + * @returns + */ +export function getReduxStoreFromDOM (): ScratchReduxStore | null { + const internalRoots = Array.from(document.querySelectorAll('*')).map((el) => { + const key = Object.keys(el).filter((keyName) => keyName.includes('__reactContainer')).at(-1); + return el[key]; + }).filter((key) => key); + + for (const root of internalRoots) { + const seen = new Map(); + const stores = new Set(); + + const search = (obj) => { + if (seen.has(obj)) { + return; + } + seen.set(obj, true); + + for (const name in obj) { + if (name === 'getState') { + const store = obj as ScratchReduxStore; + const state = store.getState(); + if (state?.scratchGui?.vm && state.scratchPaint && state.locales) { + return store; // Found target store + } + stores.add(obj); + } + + // eslint-disable-next-line no-prototype-builtins + if ((obj?.hasOwnProperty?.(name)) && (typeof obj[name] === 'object') && (obj[name] !== null)) { + const result = search(obj[name]); + if (result) return result; // Propagate found store + } + } + }; + + const result = search(root); + if (result) return result; + } + return null; +}