Skip to content

Commit

Permalink
✨ feat: add new trap method
Browse files Browse the repository at this point in the history
Signed-off-by: SimonShiki <[email protected]>
  • Loading branch information
SimonShiki committed Nov 26, 2024
1 parent 3cbf7ba commit a1f354f
Show file tree
Hide file tree
Showing 4 changed files with 185 additions and 8 deletions.
2 changes: 2 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
2 changes: 2 additions & 0 deletions locales/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 内部实例"
}
109 changes: 103 additions & 6 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -69,6 +73,8 @@ const settings = settingsAgent.getSettings();
applyPatchesForVM(vm, eureka);
}
} catch (e) {
if (vmTrapped) return;

log.error(
formatMessage({
id: 'eureka.failedToGetVM',
Expand All @@ -77,9 +83,10 @@ const settings = settingsAgent.getSettings();
, '\n', e);
}
}
})();
};

(async () => {
// Trap for Redux
const trapRedux = async () => {
if (settings.trap.redux) {
try {
log.info(
Expand Down Expand Up @@ -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;
Expand Down
80 changes: 78 additions & 2 deletions src/main/trap/redux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,46 @@ import log from '../util/console';

export interface EuRedux {
target: EventTarget;
state: any;
dispatch(action: unknown): unknown;
state: DucktypedState;
dispatch(action: DucktypedAction): unknown;
}

interface MiddlewareAPI<S, A> {
getState: () => S;
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<string, string>;
messagesByLocale: Record<string, Record<string, string>>;
}

interface DucktypedGUIState {
vm: DucktypedVM;
[key: string]: unknown
}

type Middleware<S, A> = (api: MiddlewareAPI<S, A>) => (next: (action: A) => void) => (action: A) => void;

type ScratchReduxStore = MiddlewareAPI<DucktypedState, DucktypedAction>;

class ReDucks {
static compose<S> (...composeArgs: ((arg: S) => S)[]): (arg: S) => S {
if (composeArgs.length === 0) return (args: S) => args;
Expand Down Expand Up @@ -105,3 +134,50 @@ export function getRedux (): Promise<EuRedux> {
}
});
}

/**
* 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<ScratchReduxStore>();

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;
}

0 comments on commit a1f354f

Please sign in to comment.