|
| 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); |
0 commit comments