full suport for models and hotloading Plugins

This commit is contained in:
2026-08-04 22:28:39 -05:00
parent be8c1a9ea8
commit 552f570efb
9 changed files with 236 additions and 7 deletions
@@ -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),
),
]
+1
View File
@@ -209,6 +209,7 @@ class Version(models.Model):
pack_format = models.IntegerField(null=True, blank=True) pack_format = models.IntegerField(null=True, blank=True)
pack_description = models.TextField(blank=True, default='') pack_description = models.TextField(blank=True, default='')
mods_manifest = models.JSONField(default=dict, blank=True) 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_id = models.CharField(max_length=128, blank=True, default='', db_index=True)
animation_manifest = models.JSONField(default=dict, blank=True) animation_manifest = models.JSONField(default=dict, blank=True)
models_manifest = models.JSONField(default=dict, blank=True) models_manifest = models.JSONField(default=dict, blank=True)
+2 -2
View File
@@ -1030,8 +1030,8 @@ class UGCZipParsingTests(TestCase):
from library.zips import parse_mods_manifest from library.zips import parse_mods_manifest
path = self._write_zip(_zip_bytes({ path = self._write_zip(_zip_bytes({
'modrinth.index.json': json.dumps({ 'modrinth.index.json': json.dumps({
'dependencies': [ 'files': [
{'project_id': 'abc', 'file_name': 'mod-a.jar', 'dependency_type': 'required'}, {'path': 'mods/mod-a.jar', 'env': {'client': 'required', 'server': 'required'}},
], ],
}), }),
}), suffix='.mrpack') }), suffix='.mrpack')
+20
View File
@@ -41,6 +41,7 @@ from .zips import (
read_models_manifest, read_models_manifest,
read_pack_meta, read_pack_meta,
read_pack_mcmeta, read_pack_mcmeta,
read_plugins_manifest,
) )
@@ -331,6 +332,7 @@ def project_detail(request, slug):
models_manifest = None models_manifest = None
logical_manifest = None logical_manifest = None
mods_manifest = None mods_manifest = None
plugins_manifest = None
anim_stats = None anim_stats = None
latest = versions.first() latest = versions.first()
if project.category == 'guide' and latest is not None: 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 models_manifest = latest.models_manifest or None
logical_manifest = latest.logical_manifest or None logical_manifest = latest.logical_manifest or None
mods_manifest = latest.mods_manifest or None mods_manifest = latest.mods_manifest or None
plugins_manifest = latest.plugins_manifest or None
if animation_manifest: if animation_manifest:
anims = animation_manifest.get('animations') or [] anims = animation_manifest.get('animations') or []
entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player') 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 [])}), '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 = { context = {
'project': project, 'project': project,
'versions': versions, 'versions': versions,
@@ -364,6 +375,8 @@ def project_detail(request, slug):
'models_manifest': models_manifest, 'models_manifest': models_manifest,
'logical_manifest': logical_manifest, 'logical_manifest': logical_manifest,
'mods_manifest': mods_manifest, 'mods_manifest': mods_manifest,
'plugins_manifest': plugins_manifest,
'autoload_plugins_json': autoload_plugins_json,
'anim_stats': anim_stats, 'anim_stats': anim_stats,
} }
return render(request, 'library/project_detail.html', context) 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.animation_manifest = read_animation_manifest(path) or {}
version.models_manifest = read_models_manifest(path, version.project.category) or {} version.models_manifest = read_models_manifest(path, version.project.category) or {}
version.logical_manifest = read_logical_manifest(path) 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) manifest = parse_mods_manifest(path, release.file.original_filename)
version.mods_manifest = manifest if manifest.get('files') else {} version.mods_manifest = manifest if manifest.get('files') else {}
fmt, desc = read_pack_mcmeta(path) fmt, desc = read_pack_mcmeta(path)
@@ -659,6 +673,7 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
pack_format = None pack_format = None
pack_description = '' pack_description = ''
mods_manifest = {} mods_manifest = {}
plugins_manifest = None
animation_id = '' animation_id = ''
animation_manifest = None animation_manifest = None
models_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) manifest = parse_mods_manifest(path, index.original_filename)
if manifest.get('files'): if manifest.get('files'):
mods_manifest = manifest mods_manifest = manifest
if plugins_manifest is None and project.category == 'model':
plugins_manifest = read_plugins_manifest(path)
update_fields = [] update_fields = []
if pack_format is not None: if pack_format is not None:
@@ -710,6 +727,9 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
if mods_manifest: if mods_manifest:
version.mods_manifest = mods_manifest version.mods_manifest = mods_manifest
update_fields.append('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: if animation_id:
version.animation_id = animation_id version.animation_id = animation_id
update_fields.append('animation_id') update_fields.append('animation_id')
+68 -1
View File
@@ -16,6 +16,71 @@ PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb') 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): def _decode(data):
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs).""" """Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
return json.loads(data.decode('utf-8-sig', 'replace')) return json.loads(data.decode('utf-8-sig', 'replace'))
@@ -668,7 +733,9 @@ def parse_mods_manifest(path, filename=''):
parts = name.split('/') parts = name.split('/')
if 'mods' not in parts or parts.index('mods') >= len(parts) - 1: if 'mods' not in parts or parts.index('mods') >= len(parts) - 1:
continue continue
if 'minecraft' in parts or '.minecraft' in parts: # Full instance = .minecraft/mods or a nested <instance>/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 is_instance = True
fname = parts[-1] fname = parts[-1]
if fname and fname not in seen: if fname and fname not in seen:
+54 -2
View File
@@ -3382,10 +3382,11 @@ a.deletelink {
gap: 0.5rem; gap: 0.5rem;
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
border-radius: 6px; 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; 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-name { font-weight: 600; }
.mods-file { color: var(--md-sys-color-on-surface-variant, #777); font-size: 0.78rem; } .mods-file { color: var(--md-sys-color-on-surface-variant, #777); font-size: 0.78rem; }
.mods-badge { .mods-badge {
@@ -3432,3 +3433,54 @@ a.deletelink {
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 600; 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; }
+11
View File
@@ -177,6 +177,7 @@
const member = btn.dataset.member; const member = btn.dataset.member;
const name = btn.dataset.name || member; const name = btn.dataset.name || member;
const modelFormat = btn.dataset.modelFormat || '.geo.json'; 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 }; const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null };
if (!member || !opts.baseUrl || busy) return; if (!member || !opts.baseUrl || busy) return;
busy = true; busy = true;
@@ -223,6 +224,16 @@
busy = false; busy = false;
return; 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) { if (EMBEDDED_FORMATS.indexOf(modelFormat) !== -1) {
// Textures baked into the file — hand it to Blockbench as-is. // Textures baked into the file — hand it to Blockbench as-is.
msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) }; msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) };
@@ -103,9 +103,27 @@
} }
} }
await new Promise((r) => setTimeout(r, 400)); await new Promise((r) => setTimeout(r, 400));
await hotloadPackPlugins(msg.plugins);
notify('model-open', { name: name }); 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) => { window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'packs-open-model') return; if (!e.data || e.data.type !== 'packs-open-model') return;
openModel(e.data).then( openModel(e.data).then(
+44 -2
View File
@@ -73,6 +73,7 @@
{% if models_manifest %}<button class="tab-btn" data-tab="models" role="tab">Models/Textures</button>{% endif %} {% if models_manifest %}<button class="tab-btn" data-tab="models" role="tab">Models/Textures</button>{% endif %}
{% if logical_manifest %}<button class="tab-btn" data-tab="logical" role="tab">Logical</button>{% endif %} {% if logical_manifest %}<button class="tab-btn" data-tab="logical" role="tab">Logical</button>{% endif %}
{% if mods_manifest %}<button class="tab-btn" data-tab="mods" role="tab">Mods</button>{% endif %} {% if mods_manifest %}<button class="tab-btn" data-tab="mods" role="tab">Mods</button>{% endif %}
{% if plugins_manifest %}<button class="tab-btn" data-tab="plugins" role="tab">Plugins</button>{% endif %}
<button class="tab-btn" data-tab="gallery" role="tab">Gallery</button> <button class="tab-btn" data-tab="gallery" role="tab">Gallery</button>
</div> </div>
@@ -250,6 +251,8 @@
data-member="{{ model.member }}" data-name="{{ model.name }}" data-member="{{ model.member }}" data-name="{{ model.name }}"
data-model-format="{{ model.ext }}" data-model-format="{{ model.ext }}"
data-default-texture="{{ model.default_texture }}" 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-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}"
data-vanilla-base-url="/static/vanilla/ASSET"><i class="fas fa-cube"></i> Render</button> data-vanilla-base-url="/static/vanilla/ASSET"><i class="fas fa-cube"></i> Render</button>
{% else %} {% else %}
@@ -390,10 +393,37 @@
</section> </section>
{% endif %} {% endif %}
{% if plugins_manifest %}
<section class="tab-panel" id="tab-plugins">
<div class="card">
<h2><i class="fas fa-puzzle-piece"></i> Plugins</h2>
<div class="plugins-warning">
<i class="fas fa-exclamation-triangle"></i> <strong>Caution:</strong> 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.
</div>
<div class="plugins-toggle">
<label class="switch">
<input type="checkbox" id="plugins-hotload-toggle" checked>
<span class="slider"></span>
</label>
<span>Hotload this pack's plugins when opening a model</span>
</div>
<ul class="plugins-list">
{% for plugin in plugins_manifest.plugins %}
<li>
<i class="fas fa-file-code"></i>
<span class="plugins-name">{{ plugin.name }}</span>
{% if plugin.autoload %}<span class="plugins-badge autoload">autoload</span>{% endif %}
<span class="plugins-size">{{ plugin.size|filesizeformat }}</span>
</li>
{% endfor %}
</ul>
</div>
</section>
{% endif %}
<section class="tab-panel" id="tab-gallery"> <section class="tab-panel" id="tab-gallery">
<div class="card"> <div class="card">
<h2><i class="fas fa-images"></i> Gallery</h2> <h2><i class="fas fa-images"></i> Gallery</h2> <div class="gallery-grid">
<div class="gallery-grid">
{% for asset in assets %} {% for asset in assets %}
<figure class="gallery-item" data-media-url="{{ asset.file_url }}" data-media-type="{% if asset.is_video %}video{% else %}image{% endif %}" data-caption="{{ asset.caption }}"> <figure class="gallery-item" data-media-url="{{ asset.file_url }}" data-media-type="{% if asset.is_video %}video{% else %}image{% endif %}" data-caption="{{ asset.caption }}">
{% if asset.is_video or asset.is_gif %} {% if asset.is_video or asset.is_gif %}
@@ -537,6 +567,18 @@
}); });
}); });
} }
// Per-project "Don't hotload" switch for pack plugins.
const pluginsToggle = document.getElementById('plugins-hotload-toggle');
const pluginsSlug = document.querySelector('.model-render-btn')?.dataset.projectSlug;
if (pluginsToggle && pluginsSlug) {
const key = 'packs:no-hotload:' + pluginsSlug;
if (localStorage.getItem(key) === '1') pluginsToggle.checked = false;
pluginsToggle.addEventListener('change', () => {
if (pluginsToggle.checked) localStorage.removeItem(key);
else localStorage.setItem(key, '1');
});
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}