Bundling Extensions
Extensions in Novon are distributed as .novext files.
A .novext file is a standard ZIP archive containing the following required files:
manifest.jsonsource.jsicon.png
While you can bundle extensions manually by compressing these three files into a ZIP archive, managing an entire repository with dozens of sources is more efficient using our automated tooling.
Novon provides an official bundle_extensions.py script that automates zipping, SHA-256 integrity hashing, and index.json registry generation.
The Python Bundler
If you host an extension repository, you must maintain an index.json file. This registry contains the cryptographic SHA-256 hashes for every extension, allowing the Dart client to verify downloads.
The bundle_extensions.py script performs the following tasks:
- Iterates through all subdirectories in your source folder.
- Packages them into versioned
.novextbundles. - Computes the required SHA-256 integrity hashes.
- Generates a valid
index.jsonregistry file automatically.
Usage
Prerequisites: Python 3.8 or higher.
python bundle_extensions.py
Source Code
Save the following script as bundle_extensions.py in the root of your extension repository.
import os
import json
import zipfile
import hashlib
import glob
import datetime
EXTENSIONS_DIR = "src"
OUTPUT_DIR = "bundles"
INDEX_FILE = "index.json"
REPO_NAME = "My Custom Extensions"
REPO_URL = "https://raw.githubusercontent.com/username/my-repo/main/index.json"
MAINTAINER_URL = "https://github.com/username"
DOWNLOAD_BASE = "https://raw.githubusercontent.com/username/my-repo/main/bundles"
ICON_BASE = "https://raw.githubusercontent.com/username/my-repo/main/icons"
def calculate_sha256(filepath):
sha256_hash = hashlib.sha256()
with open(filepath, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def ensure_dir(path):
if not os.path.exists(path):
os.makedirs(path)
def main():
print(f"Bundling extensions from {EXTENSIONS_DIR}...")
ensure_dir(OUTPUT_DIR)
ensure_dir("icons")
extensions_registry = []
for ext_path in glob.glob(os.path.join(EXTENSIONS_DIR, "*")):
if not os.path.isdir(ext_path):
continue
manifest_path = os.path.join(ext_path, "manifest.json")
if not os.path.exists(manifest_path):
continue
with open(manifest_path, 'r', encoding='utf-8') as f:
try:
manifest = json.load(f)
except json.JSONDecodeError:
print(f"Error: Invalid JSON in {manifest_path}")
continue
ext_id = manifest.get('id')
ext_version = manifest.get('version')
if not ext_id or not ext_version:
print(f"Error: Missing id or version in {ext_id}")
continue
icon_path = os.path.join(ext_path, manifest.get('icon', 'icon.png'))
if os.path.exists(icon_path):
import shutil
shutil.copy2(icon_path, os.path.join("icons", f"{ext_id}.png"))
bundle_name = f"{ext_id}-{ext_version}.novext"
bundle_path = os.path.join(OUTPUT_DIR, bundle_name)
print(f"Bundling {bundle_name}...")
with zipfile.ZipFile(bundle_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file in os.listdir(ext_path):
file_path = os.path.join(ext_path, file)
if os.path.isfile(file_path):
zipf.write(file_path, file)
sha256 = calculate_sha256(bundle_path)
entry = {
"id": ext_id,
"name": manifest.get('name'),
"version": ext_version,
"apiVersion": manifest.get('apiVersion', "2"),
"minAppVersion": manifest.get('minAppVersion', "1.0.0"),
"lang": manifest.get('lang', "all"),
"nsfw": manifest.get('nsfw', False),
"hasCloudflare": manifest.get('hasCloudflare', False),
"categories": manifest.get('categories', []),
"icon": f"{ICON_BASE}/{ext_id}.png",
"downloadUrl": f"{DOWNLOAD_BASE}/{bundle_name}",
"sha256": sha256,
"updatedAt": datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
}
extensions_registry.append(entry)
registry = {
"repoName": REPO_NAME,
"repoUrl": REPO_URL,
"maintainerUrl": MAINTAINER_URL,
"generated": datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
"apiVersion": "2",
"extensions": extensions_registry
}
with open(INDEX_FILE, 'w', encoding='utf-8') as f:
json.dump(registry, f, indent=2, ensure_ascii=False)
print(f"Successfully bundled {len(extensions_registry)} extensions.")
if __name__ == "__main__":
main()
Continuous Deployment
You can host your repository using GitHub Pages or raw Git endpoints. We recommend using a GitHub Action to automate the bundling process whenever you push changes to your main branch.
name: Build Extensions
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Run Bundler
run: python bundle_extensions.py
- name: Commit Bundles
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "Auto-bundle extensions"
file_pattern: "bundles/* icons/* index.json"