diff --git a/nonpacks/library/migrations/0009_version_plugins_manifest.py b/nonpacks/library/migrations/0009_version_plugins_manifest.py new file mode 100644 index 0000000..fff6ce8 --- /dev/null +++ b/nonpacks/library/migrations/0009_version_plugins_manifest.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-08-05 02:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0008_version_logical_manifest_version_models_manifest'), + ] + + operations = [ + migrations.AddField( + model_name='version', + name='plugins_manifest', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index 5a82390..ffbacfc 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -209,6 +209,7 @@ class Version(models.Model): pack_format = models.IntegerField(null=True, blank=True) pack_description = models.TextField(blank=True, default='') mods_manifest = models.JSONField(default=dict, blank=True) + plugins_manifest = models.JSONField(default=dict, blank=True) animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True) animation_manifest = models.JSONField(default=dict, blank=True) models_manifest = models.JSONField(default=dict, blank=True) diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py index cd252f0..77eb163 100644 --- a/nonpacks/library/tests.py +++ b/nonpacks/library/tests.py @@ -1030,8 +1030,8 @@ class UGCZipParsingTests(TestCase): from library.zips import parse_mods_manifest path = self._write_zip(_zip_bytes({ 'modrinth.index.json': json.dumps({ - 'dependencies': [ - {'project_id': 'abc', 'file_name': 'mod-a.jar', 'dependency_type': 'required'}, + 'files': [ + {'path': 'mods/mod-a.jar', 'env': {'client': 'required', 'server': 'required'}}, ], }), }), suffix='.mrpack') diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index 94260ba..c971d6a 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -41,6 +41,7 @@ from .zips import ( read_models_manifest, read_pack_meta, read_pack_mcmeta, + read_plugins_manifest, ) @@ -331,6 +332,7 @@ def project_detail(request, slug): models_manifest = None logical_manifest = None mods_manifest = None + plugins_manifest = None anim_stats = None latest = versions.first() if project.category == 'guide' and latest is not None: @@ -340,6 +342,7 @@ def project_detail(request, slug): models_manifest = latest.models_manifest or None logical_manifest = latest.logical_manifest or None mods_manifest = latest.mods_manifest or None + plugins_manifest = latest.plugins_manifest or None if animation_manifest: anims = animation_manifest.get('animations') or [] entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player') @@ -353,6 +356,14 @@ def project_detail(request, slug): 'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}), } + autoload_plugins_json = '' + if plugins_manifest: + members = [ + p['member'] for p in plugins_manifest.get('plugins', []) + if p.get('autoload') and p.get('member') + ] + autoload_plugins_json = json.dumps(members) + context = { 'project': project, 'versions': versions, @@ -364,6 +375,8 @@ def project_detail(request, slug): 'models_manifest': models_manifest, 'logical_manifest': logical_manifest, 'mods_manifest': mods_manifest, + 'plugins_manifest': plugins_manifest, + 'autoload_plugins_json': autoload_plugins_json, 'anim_stats': anim_stats, } return render(request, 'library/project_detail.html', context) @@ -638,6 +651,7 @@ def _reparse_version(version): version.animation_manifest = read_animation_manifest(path) or {} version.models_manifest = read_models_manifest(path, version.project.category) or {} version.logical_manifest = read_logical_manifest(path) or {} + version.plugins_manifest = read_plugins_manifest(path) or {} if version.project.category == 'model' else {} manifest = parse_mods_manifest(path, release.file.original_filename) version.mods_manifest = manifest if manifest.get('files') else {} fmt, desc = read_pack_mcmeta(path) @@ -659,6 +673,7 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): pack_format = None pack_description = '' mods_manifest = {} + plugins_manifest = None animation_id = '' animation_manifest = None models_manifest = None @@ -701,6 +716,8 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): manifest = parse_mods_manifest(path, index.original_filename) if manifest.get('files'): mods_manifest = manifest + if plugins_manifest is None and project.category == 'model': + plugins_manifest = read_plugins_manifest(path) update_fields = [] if pack_format is not None: @@ -710,6 +727,9 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): if mods_manifest: version.mods_manifest = mods_manifest update_fields.append('mods_manifest') + if plugins_manifest is not None: + version.plugins_manifest = plugins_manifest + update_fields.append('plugins_manifest') if animation_id: version.animation_id = animation_id update_fields.append('animation_id') diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py index 0c6c6c5..cf786db 100644 --- a/nonpacks/library/zips.py +++ b/nonpacks/library/zips.py @@ -16,6 +16,71 @@ PROBLEM_TAGS = {'broken', 'bugged', 'borked'} MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb') +def _in_plugins_dir(name): + """True when a member lives inside a plugins/ folder (any nesting).""" + parts = name.split('/') + return 'plugins' in parts[:-1] + + +def read_plugins_manifest(path): + """Scan the archive's plugins/ folder for Blockbench plugins. + + A ``.js`` file under a ``plugins/`` directory that registers a Blockbench + plugin (contains ``Plugin.register(`` or ``BBPlugin.register(``) is listed. + ``plugins/autoload.txt`` holds one plugin name per line; matching plugins + are marked ``autoload`` so the viewer can hotload them when a model opens. + Returns None when the pack has no plugins. + """ + zf = _open_zip(path) + if zf is None: + return None + try: + names = set(zf.namelist()) + autoload = set() + for n in names: + if _in_plugins_dir(n) and n.endswith('autoload.txt'): + try: + raw = zf.read(n).decode('utf-8', 'replace') + except KeyError: + continue + for line in raw.splitlines(): + line = line.strip() + if line and not line.startswith('#'): + autoload.add(line) + + plugins = [] + for n in sorted(names): + if not _in_plugins_dir(n) or not n.endswith('.js'): + continue + try: + content = zf.read(n) + except KeyError: + continue + if len(content) > 8 * 1024 * 1024: + continue + text = content.decode('utf-8', 'replace') + if 'Plugin.register(' not in text and 'BBPlugin.register(' not in text: + continue + base = n.rsplit('/', 1)[-1] + stem = base[:-3] if base.lower().endswith('.js') else base + plugins.append({ + 'name': base, + 'member': n, + 'size': len(content), + 'autoload': base in autoload or stem in autoload, + }) + + if not plugins: + return None + return { + 'plugins': plugins, + 'has_plugins': True, + 'autoload_count': sum(1 for p in plugins if p['autoload']), + } + finally: + zf.close() + + def _decode(data): """Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs).""" return json.loads(data.decode('utf-8-sig', 'replace')) @@ -668,7 +733,9 @@ def parse_mods_manifest(path, filename=''): parts = name.split('/') if 'mods' not in parts or parts.index('mods') >= len(parts) - 1: continue - if 'minecraft' in parts or '.minecraft' in parts: + # Full instance = .minecraft/mods or a nested /minecraft/mods; + # a root-level minecraft/mods folder is the classic folder layout. + if '.minecraft' in parts or (('minecraft' in parts) and parts.index('minecraft') > 0): is_instance = True fname = parts[-1] if fname and fname not in seen: diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index c04dea9..cb7ab8b 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -3382,10 +3382,11 @@ a.deletelink { gap: 0.5rem; padding: 0.35rem 0.6rem; border-radius: 6px; - background: var(--md-sys-color-surface-container-low, #f7f7f9); + background: var(--md-sys-color-surface-variant, #45475a); + color: var(--md-sys-color-on-surface, #cdd6f4); font-size: 0.85rem; } -.mods-item:hover { background: var(--md-sys-color-surface-container, #efeff3); } +.mods-item:hover { background: var(--md-sys-color-surface2, #585b70); } .mods-name { font-weight: 600; } .mods-file { color: var(--md-sys-color-on-surface-variant, #777); font-size: 0.78rem; } .mods-badge { @@ -3432,3 +3433,54 @@ a.deletelink { font-size: 0.72rem; font-weight: 600; } + +/* Plugins tab */ +.plugins-warning { + display: flex; + align-items: flex-start; + gap: 0.5rem; + background: #fff4e0; + border: 1px solid #f0c878; + color: #7a4d00; + padding: 0.7rem 0.9rem; + border-radius: 8px; + margin-bottom: 0.9rem; + font-size: 0.85rem; +} +.plugins-toggle { + display: flex; + align-items: center; + gap: 0.7rem; + margin-bottom: 0.9rem; + font-size: 0.85rem; +} +.plugins-toggle .switch { position: relative; display: inline-block; width: 42px; height: 24px; flex: 0 0 auto; } +.plugins-toggle .switch input { opacity: 0; width: 0; height: 0; } +.plugins-toggle .slider { + position: absolute; cursor: pointer; inset: 0; + background: #cbd5e1; border-radius: 999px; transition: background 0.15s; +} +.plugins-toggle .slider:before { + content: ''; position: absolute; height: 18px; width: 18px; left: 3px; top: 3px; + background: #fff; border-radius: 50%; transition: transform 0.15s; +} +.plugins-toggle .switch input:checked + .slider { background: #059669; } +.plugins-toggle .switch input:checked + .slider:before { transform: translateX(18px); } +.plugins-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 0.25rem; +} +.plugins-list li { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.35rem 0.6rem; border-radius: 6px; + background: var(--md-sys-color-surface-variant, #45475a); + color: var(--md-sys-color-on-surface, #cdd6f4); + font-size: 0.85rem; +} +.plugins-name { font-weight: 600; color: inherit; } +.plugins-badge.autoload { + margin-left: auto; + border-radius: 999px; background: #e0e7ff; color: #3730a3; + padding: 0.1rem 0.5rem; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; +} +.plugins-size { color: var(--md-sys-color-on-surface-variant, #6b7280); font-size: 0.78rem; } diff --git a/nonpacks/static/js/packs_preview.js b/nonpacks/static/js/packs_preview.js index 4743ee4..c0f92ec 100644 --- a/nonpacks/static/js/packs_preview.js +++ b/nonpacks/static/js/packs_preview.js @@ -177,6 +177,7 @@ const member = btn.dataset.member; const name = btn.dataset.name || member; const modelFormat = btn.dataset.modelFormat || '.geo.json'; + const slug = btn.dataset.projectSlug || ''; const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null }; if (!member || !opts.baseUrl || busy) return; busy = true; @@ -223,6 +224,16 @@ busy = false; return; } + // Pack plugins to hotload (autoload.txt), unless the user opted out. + const noHotload = slug && localStorage.getItem('packs:no-hotload:' + slug) === '1'; + if (!noHotload && btn.dataset.plugins) { + try { + const members = JSON.parse(btn.dataset.plugins); + msg.plugins = members.map(m => ({ member: m, url: assetUrl(opts.baseUrl, m) })); + } catch (e) { + msg.plugins = []; + } + } if (EMBEDDED_FORMATS.indexOf(modelFormat) !== -1) { // Textures baked into the file — hand it to Blockbench as-is. msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) }; diff --git a/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js b/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js index 0c21bdc..8ed99f4 100644 --- a/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js +++ b/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js @@ -103,9 +103,27 @@ } } await new Promise((r) => setTimeout(r, 400)); + await hotloadPackPlugins(msg.plugins); notify('model-open', { name: name }); } + // Hotload plugins bundled with the pack (served via pack_asset). A failing + // plugin is reported but never blocks the model preview. + async function hotloadPackPlugins(plugins) { + for (const p of (plugins || [])) { + if (!p || !p.url || !p.member) continue; + try { + const code = await (await fetch(p.url, { headers: { 'Accept': 'text/javascript' } })).text(); + if (!code || code.length < 20) continue; + await new Plugin().loadFromFile({ path: p.member, content: code }, true); + notify('plugin-hotloaded', { name: p.member }); + } catch (e) { + console.error('packs pack plugin hotload failed', p.member, e); + notify('plugin-error', { error: 'Failed to load plugin ' + p.member }); + } + } + } + window.addEventListener('message', (e) => { if (!e.data || e.data.type !== 'packs-open-model') return; openModel(e.data).then( diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index 5a77396..eacb440 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -73,6 +73,7 @@ {% if models_manifest %}{% endif %} {% if logical_manifest %}{% endif %} {% if mods_manifest %}{% endif %} + {% if plugins_manifest %}{% endif %} @@ -250,6 +251,8 @@ data-member="{{ model.member }}" data-name="{{ model.name }}" data-model-format="{{ model.ext }}" data-default-texture="{{ model.default_texture }}" + data-project-slug="{{ project.slug }}" + data-plugins="{{ autoload_plugins_json }}" data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}" data-vanilla-base-url="/static/vanilla/ASSET"> Render {% else %} @@ -390,10 +393,37 @@ {% endif %} +{% if plugins_manifest %} +
+
+

Plugins

+
+ Caution: this pack contains Blockbench plugins — executable JavaScript that runs in your browser. Plugins can crash Blockbench or run arbitrary code. Only open models from packs you trust. +
+
+ + Hotload this pack's plugins when opening a model +
+
    + {% for plugin in plugins_manifest.plugins %} +
  • + + {{ plugin.name }} + {% if plugin.autoload %}autoload{% endif %} + {{ plugin.size|filesizeformat }} +
  • + {% endfor %} +
+
+
+{% endif %} +