Skip to content

Commit b7790db

Browse files
committed
add load extfind extension
1 parent ea19074 commit b7790db

8 files changed

Lines changed: 259 additions & 10 deletions

File tree

src/containers/blocks.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1852,7 +1852,7 @@ const mapDispatchToProps = dispatch => ({
18521852
dispatch(activateTab(SOUNDS_TAB_INDEX));
18531853
dispatch(openSoundRecorder());
18541854
},
1855-
reduxOnOpenCustomExtensionModal: () => dispatch(openCustomExtensionModal()),
1855+
reduxOnOpenCustomExtensionModal: data => dispatch(openCustomExtensionModal(data)),
18561856
onOpenExtensionImportMethodModal: () => dispatch(openExtensionImportMethodModal()),
18571857
onSetSelectedExtension: extension => dispatch(setSelectedExtension(extension)),
18581858
onSetSelectedExtensions: extensions => dispatch(setSelectedExtensions(extensions)),

src/containers/extension-library.jsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,35 @@ const getURLStem = value => {
281281
}
282282
};
283283

284+
const runRawScriptFromURL = async extensionURL => {
285+
const response = await fetch(extensionURL);
286+
if (!response.ok) {
287+
throw new Error(`HTTP error! status: ${response.status}`);
288+
}
289+
const source = await response.text();
290+
const vm = window.vm;
291+
const Scratch = {
292+
vm,
293+
runtime: vm?.runtime,
294+
renderer: vm?.runtime?.renderer,
295+
extensions: {
296+
unsandboxed: true,
297+
register: () => {}
298+
}
299+
};
300+
const previousScratch = window.Scratch;
301+
window.Scratch = Scratch;
302+
try {
303+
Function('Scratch', `${source}\n//# sourceURL=${extensionURL}`)(Scratch);
304+
} finally {
305+
if (previousScratch === undefined) {
306+
delete window.Scratch;
307+
} else {
308+
window.Scratch = previousScratch;
309+
}
310+
}
311+
};
312+
284313
const getNameText = (intl, value) => {
285314
if (typeof value === 'string') {
286315
return value;
@@ -836,6 +865,16 @@ class ExtensionLibrary extends React.PureComponent {
836865
return;
837866
}
838867

868+
if (extensionId === 'extfind') {
869+
runRawScriptFromURL(extensionURL)
870+
.catch(err => {
871+
log.error(err);
872+
// eslint-disable-next-line no-alert
873+
alert(err);
874+
});
875+
return;
876+
}
877+
839878
if (extensionId === 'procedures_enable_return') {
840879
this.handleEnableProcedureReturns();
841880
return;

src/containers/tw-custom-extension-modal.jsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ class CustomExtensionModal extends React.Component {
4040
]);
4141

4242
this.state = {
43-
type: 'url',
43+
type: props.initialType || 'url',
4444
url: '',
4545
files: null,
46-
text: '',
46+
text: props.initialText || '',
4747
unsandboxed: getPersistedUnsandboxed()
4848
};
4949
}
@@ -168,6 +168,15 @@ class CustomExtensionModal extends React.Component {
168168
});
169169
}
170170

171+
componentDidUpdate (prevProps) {
172+
if (prevProps.initialText !== this.props.initialText || prevProps.initialType !== this.props.initialType) {
173+
this.setState({
174+
type: this.props.initialType || 'url',
175+
text: this.props.initialText || ''
176+
});
177+
}
178+
}
179+
171180
handleDragOver (e) {
172181
if (e.dataTransfer.types.includes('Files')) {
173182
e.preventDefault();
@@ -236,6 +245,8 @@ class CustomExtensionModal extends React.Component {
236245

237246
CustomExtensionModal.propTypes = {
238247
onClose: PropTypes.func,
248+
initialType: PropTypes.oneOf(['url', 'file', 'text']),
249+
initialText: PropTypes.string,
239250
vm: PropTypes.shape({
240251
extensionManager: PropTypes.shape({
241252
loadExtensionURL: PropTypes.func
@@ -244,6 +255,8 @@ CustomExtensionModal.propTypes = {
244255
};
245256

246257
const mapStateToProps = state => ({
258+
initialType: state.scratchGui.modals.customExtensionModalData?.initialType,
259+
initialText: state.scratchGui.modals.customExtensionModalData?.initialText,
247260
vm: state.scratchGui.vm
248261
});
249262

src/lib/libraries/extensions/index.jsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import twIcon from './tw/tw.svg';
5050
import customExtensionIcon from './custom/custom.svg';
5151
import returnIcon from './custom/return.svg';
5252
import galleryIcon from './gallery/gallery.svg';
53+
import extfindIconURL from '../../../../static/extensions/ExtFind.svg';
5354
import {APP_NAME} from '../../brand';
5455

5556
export default [
@@ -401,6 +402,17 @@ export default [
401402
tags: ['tw'],
402403
featured: true
403404
// Not marked as incompatible with Scratch so that clicking on it doesn't show a prompt
405+
},
406+
{
407+
name: '加载ExtFind扩展',
408+
extensionId: 'extfind',
409+
extensionURL: `${process.env.ROOT}static/extensions/ExtFind.js`,
410+
iconURL: extfindIconURL,
411+
description: '从 ExtFind 加载扩展,可以从 https://extfind.0pen.top/ 获取。',
412+
tags: [],
413+
featured: true,
414+
incompatibleWithScratch: false,
415+
internetConnectionRequired: true
404416
}
405417
];
406418

src/reducers/modals.js

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,26 @@ const initialState = {
5252
[MODAL_INVALID_PROJECT]: false,
5353
[MODAL_GIT]: false,
5454
selectedExtension: null,
55-
selectedExtensions: []
55+
selectedExtensions: [],
56+
customExtensionModalData: null
5657
};
5758

5859
const reducer = function (state, action) {
5960
if (typeof state === 'undefined') state = initialState;
6061
switch (action.type) {
6162
case OPEN_MODAL:
6263
return Object.assign({}, state, {
63-
[action.modal]: true
64+
[action.modal]: true,
65+
...(action.modal === MODAL_CUSTOM_EXTENSION ? {
66+
customExtensionModalData: action.data || null
67+
} : {})
6468
});
6569
case CLOSE_MODAL:
6670
return Object.assign({}, state, {
67-
[action.modal]: false
71+
[action.modal]: false,
72+
...(action.modal === MODAL_CUSTOM_EXTENSION ? {
73+
customExtensionModalData: null
74+
} : {})
6875
});
6976
case SET_SELECTED_EXTENSION:
7077
return Object.assign({}, state, {
@@ -78,10 +85,11 @@ const reducer = function (state, action) {
7885
return state;
7986
}
8087
};
81-
const openModal = function (modal) {
88+
const openModal = function (modal, data) {
8289
return {
8390
type: OPEN_MODAL,
84-
modal: modal
91+
modal: modal,
92+
data
8593
};
8694
};
8795
const closeModal = function (modal) {
@@ -135,8 +143,8 @@ const openToolboxLayoutModal = function () {
135143
const openSpriteLayerModal = function () {
136144
return openModal(MODAL_SPRITE_LAYER);
137145
};
138-
const openCustomExtensionModal = function () {
139-
return openModal(MODAL_CUSTOM_EXTENSION);
146+
const openCustomExtensionModal = function (data) {
147+
return openModal(MODAL_CUSTOM_EXTENSION, data);
140148
};
141149
const openCCWExtensionModal = function () {
142150
return openModal(MODAL_CCW_EXTENSION);

static/extensions/ExtFind.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// ExtFind TurboWarp loader extension.
2+
// Loads published extensions from https://extfindbackend.0pen.top by extension ID.
3+
(function (Scratch) {
4+
'use strict';
5+
6+
if (!Scratch.extensions.unsandboxed) {
7+
throw new Error('ExtFind must run unsandboxed');
8+
}
9+
10+
const API_BASE = 'https://extfindbackend.0pen.top/api';
11+
12+
const css = `
13+
.extfind-backdrop{position:fixed;inset:0;z-index:2147483647;display:grid;place-items:center;background:rgba(0,0,0,.35);font-family:Inter,Roboto,Arial,sans-serif}
14+
.extfind-dialog{width:min(460px,calc(100vw - 32px));border-radius:24px;background:#f8fffd;color:#173330;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:24px;box-sizing:border-box}
15+
.extfind-dialog h2{margin:0 0 8px;font-size:24px;line-height:1.2}
16+
.extfind-dialog p{margin:0 0 18px;color:#52615f;font-size:14px;line-height:1.5}
17+
.extfind-field{display:flex;gap:8px;margin-bottom:14px}
18+
.extfind-input,.extfind-select{width:100%;border:1px solid #b8ccca;border-radius:12px;background:#fff;padding:12px 14px;font:inherit;color:#173330;box-sizing:border-box}
19+
.extfind-button{border:0;border-radius:999px;background:#00baad;color:#00201d;padding:11px 18px;font-weight:700;cursor:pointer;white-space:nowrap}
20+
.extfind-button.secondary{background:#d7f5f1;color:#173330}
21+
.extfind-button.text{background:transparent;color:#52615f}
22+
.extfind-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
23+
.extfind-status{min-height:20px;margin-top:10px;color:#52615f;font-size:13px}
24+
.extfind-card{display:none;margin-top:14px;border:1px solid #cce4e1;border-radius:16px;padding:14px;background:#fff}
25+
.extfind-card strong{display:block;margin-bottom:6px}
26+
`;
27+
28+
const sanitizeId = (value) => String(value || '').trim();
29+
const pickLatest = (versions) => versions.find((version) => version.isLatest) || versions[0];
30+
const pickPrimaryFile = (version) => version?.files?.find((file) => file.isPrimary) || version?.files?.[0];
31+
32+
const loadExtensionUrl = async (url) => {
33+
const response = await fetch(url);
34+
if (!response.ok) throw new Error(`文件下载失败:${response.status}`);
35+
const code = await response.text();
36+
Function('Scratch', `${code}\n//# sourceURL=${url}`)(Scratch);
37+
};
38+
39+
const createDialog = () => {
40+
const existing = document.querySelector('.extfind-backdrop');
41+
if (existing) existing.remove();
42+
43+
const style = document.createElement('style');
44+
style.textContent = css;
45+
document.head.appendChild(style);
46+
47+
const backdrop = document.createElement('div');
48+
backdrop.className = 'extfind-backdrop';
49+
backdrop.innerHTML = `
50+
<div class="extfind-dialog" role="dialog" aria-modal="true" aria-label="ExtFind 扩展加载器">
51+
<h2>ExtFind 扩展加载器</h2>
52+
<p>从 <a href="https://extfind.0pen.top/" target="_blank" rel="noreferrer">extfind.0pen.top</a> 获取、上传和点评扩展,并输入扩展 ID 后选择版本和文件加载。</p>
53+
<div class="extfind-field">
54+
<input class="extfind-input" type="text" placeholder="输入扩展 ID" autocomplete="off" />
55+
<button class="extfind-button secondary" type="button">获取</button>
56+
</div>
57+
<div class="extfind-card">
58+
<strong class="extfind-name"></strong>
59+
<p class="extfind-summary"></p>
60+
<select class="extfind-select" data-field="version"></select>
61+
<select class="extfind-select" data-field="file"></select>
62+
</div>
63+
<div class="extfind-status">请输入扩展 ID。</div>
64+
<div class="extfind-actions">
65+
<button class="extfind-button text" type="button" data-action="close">取消</button>
66+
<button class="extfind-button" type="button" data-action="load">加载扩展</button>
67+
</div>
68+
</div>
69+
`;
70+
document.body.appendChild(backdrop);
71+
return backdrop;
72+
};
73+
74+
const openLoader = () => {
75+
const dialog = createDialog();
76+
const input = dialog.querySelector('.extfind-input');
77+
const fetchButton = dialog.querySelector('.extfind-button.secondary');
78+
const loadButton = dialog.querySelector('[data-action="load"]');
79+
const closeButton = dialog.querySelector('[data-action="close"]');
80+
const status = dialog.querySelector('.extfind-status');
81+
const card = dialog.querySelector('.extfind-card');
82+
const name = dialog.querySelector('.extfind-name');
83+
const summary = dialog.querySelector('.extfind-summary');
84+
const versionSelect = dialog.querySelector('[data-field="version"]');
85+
const fileSelect = dialog.querySelector('[data-field="file"]');
86+
let extensionData = null;
87+
88+
const setStatus = (message) => {
89+
status.textContent = message;
90+
};
91+
92+
const getVersions = () => Array.isArray(extensionData?.versions) ? extensionData.versions : [];
93+
94+
const getSelectedVersion = () => {
95+
const versions = getVersions();
96+
return versions.find((item) => item.id === versionSelect.value) || pickLatest(versions);
97+
};
98+
99+
const renderFiles = () => {
100+
const version = getSelectedVersion();
101+
const files = Array.isArray(version?.files) ? version.files : [];
102+
fileSelect.innerHTML = '';
103+
files.forEach((file) => {
104+
const option = document.createElement('option');
105+
option.value = file.id;
106+
option.textContent = `${file.displayName || file.originalName || '文件'}${file.isPrimary ? ' 主文件' : ''}`;
107+
fileSelect.appendChild(option);
108+
});
109+
const primary = pickPrimaryFile(version);
110+
if (primary) fileSelect.value = primary.id;
111+
};
112+
113+
const fetchInfo = async () => {
114+
const extensionId = sanitizeId(input.value);
115+
if (!extensionId) {
116+
setStatus('扩展 ID 不能为空。');
117+
input.focus();
118+
return;
119+
}
120+
setStatus('正在获取扩展信息...');
121+
const response = await fetch(`${API_BASE}/extensions/${encodeURIComponent(extensionId)}`);
122+
if (!response.ok) throw new Error(response.status === 404 ? '扩展不存在或未发布。' : `获取失败:${response.status}`);
123+
extensionData = await response.json();
124+
const versions = getVersions();
125+
versionSelect.innerHTML = '';
126+
versions.forEach((version) => {
127+
const option = document.createElement('option');
128+
option.value = version.id;
129+
option.textContent = `${version.version}${version.isLatest ? ' 最新' : ''}`;
130+
versionSelect.appendChild(option);
131+
});
132+
const latest = pickLatest(versions);
133+
if (latest) versionSelect.value = latest.id;
134+
renderFiles();
135+
name.textContent = extensionData.name || extensionId;
136+
summary.textContent = extensionData.summary || '暂无简介';
137+
card.style.display = 'block';
138+
setStatus(versions.length ? '请选择版本和文件,然后点击加载扩展。' : '这个扩展没有可用版本。');
139+
};
140+
141+
const loadSelected = async () => {
142+
if (!extensionData) await fetchInfo();
143+
if (!extensionData) return;
144+
const version = getSelectedVersion();
145+
if (!version) throw new Error('没有可加载的版本。');
146+
const files = Array.isArray(version.files) ? version.files : [];
147+
const file = files.find((item) => item.id === fileSelect.value) || pickPrimaryFile(version);
148+
if (!file) throw new Error(`版本 ${version.version} 没有文件。`);
149+
setStatus(`正在加载 ${extensionData.name} ${version.version} / ${file.displayName || file.originalName || '文件'}...`);
150+
await loadExtensionUrl(`${API_BASE}/files/${encodeURIComponent(file.id)}/download`);
151+
setStatus(`已加载 ${extensionData.name} ${version.version} / ${file.displayName || file.originalName || '文件'}`);
152+
setTimeout(() => dialog.remove(), 600);
153+
};
154+
155+
fetchButton.addEventListener('click', () => fetchInfo().catch((error) => setStatus(error.message || '获取失败。')));
156+
loadButton.addEventListener('click', () => loadSelected().catch((error) => setStatus(error.message || '加载失败。')));
157+
closeButton.addEventListener('click', () => dialog.remove());
158+
versionSelect.addEventListener('change', renderFiles);
159+
input.addEventListener('keydown', (event) => {
160+
if (event.key === 'Enter') fetchInfo().catch((error) => setStatus(error.message || '获取失败。'));
161+
});
162+
input.focus();
163+
};
164+
165+
openLoader();
166+
})(Scratch);

static/extensions/ExtFind.svg

Lines changed: 3 additions & 0 deletions
Loading

webpack.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,10 @@ module.exports = [
282282
{
283283
from: 'static',
284284
to: ''
285+
},
286+
{
287+
from: 'static/extensions',
288+
to: 'static/extensions'
285289
}
286290
]
287291
}),
@@ -345,6 +349,10 @@ module.exports = [
345349
from: 'src/lib/libraries/*.json',
346350
to: 'libraries',
347351
flatten: true
352+
},
353+
{
354+
from: 'static/extensions',
355+
to: 'static/extensions'
348356
}
349357
]
350358
})

0 commit comments

Comments
 (0)