The .mixplugin format
A .mixplugin file is the distributable form of a plugin package — a plain zip of the package folder (the one containing plugin.json), with the folder itself as the zip’s top-level entry. Unzip it next to a destination and you reproduce the package unchanged. There’s no compression trickery and no embedded code execution: packing is a deterministic file copy, and unpacking is a guarded file copy back.
This is the unit the CLI’s pack command builds, link/install consumes, and the marketplace distributes.
What goes in the zip
Section titled “What goes in the zip”pack(pkg_dir, out_path=None, sign_with=None) walks pkg_dir and includes every file except:
- hidden files/dirs (anything starting with
.), including.git __pycache__directories and.pycfiles.DS_Store
Everything else — plugin.json, the entry .py, widgets/, screen/, icons/, and any other package content — is included as-is.
Packing is deterministic on purpose:
- entries are written in sorted arcname order
- every entry gets a fixed timestamp (
1980-01-01 00:00:00)
So packing the same package folder twice produces a byte-identical .mixplugin, which makes the file itself a stable, hashable artifact (useful for the digest-based trust flow described in Signing your plugin).
Before zipping anything, pack() validates the manifest and raises ValueError if plugin.json doesn’t pass — you can’t pack an invalid package.
Packing, with or without signing
Section titled “Packing, with or without signing”mixlar-sdk pack # unsigned .mixplugin next to the packagemixlar-sdk pack --sign my-studio # sign, then packmixlar-sdk pack --out dist/my_plugin.mixpluginIf sign_with is given as (key_id, priv_b64), pack() signs the package in place first (rewriting plugin.json’s publisher/sig fields), re-reads the manifest to confirm it’s still valid, and only then zips. That means a signed .mixplugin always has a manifest whose signature matches the exact bytes shipped in the archive.
By default the output filename is <id>-<version>.mixplugin, written next to pkg_dir. Pass out_path (or --out on the CLI) to choose a different location.
Programmatically:
from mixlar import packaging
# unsignedout = packaging.pack("./my_plugin")
# signedout = packaging.pack("./my_plugin", sign_with=("my-studio", priv_b64))
# explicit output pathout = packaging.pack("./my_plugin", out_path="dist/my_plugin.mixplugin")See The mixlar-sdk CLI and Pack, sign, publish for the full command reference.
Inspecting a .mixplugin without extracting it
Section titled “Inspecting a .mixplugin without extracting it”Two read-only helpers work directly on the zip:
from mixlar import packaging
# just the manifest, parsed as a dictmanifest = packaging.manifest_of("my_plugin-1.0.0.mixplugin")
# every entry (files and folders) inside the archiveentries = packaging.contents("my_plugin-1.0.0.mixplugin")manifest_of() locates the single plugin.json at the top level of the archive (one path segment above it) and parses it with json.load — no extraction to disk. It raises ValueError if no such manifest entry exists.
contents() is a thin wrapper over zipfile.namelist(), handy for a quick sanity check before you install something.
Unpacking and installing
Section titled “Unpacking and installing”unpack(mixplugin_path, dest_dir) extracts a .mixplugin into dest_dir and returns the path to the extracted package folder (dest_dir/<pkg_name>).
Before it writes a single byte, unpack() runs path-traversal guards over every entry in the archive:
| Check | Rejects |
|---|---|
| Shared top-level folder | Archives with more than one top-level folder (ValueError: package has multiple top-level folders) |
| No absolute paths | Any entry name that’s an absolute path |
No .. segments |
Any entry name containing a .. path segment |
Resolved path stays inside dest_dir |
Any entry whose extraction target would land outside dest_dir after normalization |
| Non-empty archive | An archive with no top-level folder at all (ValueError: empty package) |
All of these checks happen before zf.extractall() runs — a malicious or corrupted .mixplugin fails loudly instead of writing outside the destination.
from mixlar import packaging
pkg_path = packaging.unpack("my_plugin-1.0.0.mixplugin", "./unpacked")# -> "./unpacked/my_plugin"install(mixplugin_path, plugins_dir=None) is unpack() aimed at the app’s default plugin directory:
from mixlar import packaging
installed_path = packaging.install("my_plugin-1.0.0.mixplugin")Without plugins_dir, it targets %APPDATA%\Mixlar\config\plugins on Windows — the same directory the running app scans for plugins, so an installed package is immediately loadable. Pass plugins_dir explicitly to install somewhere else (useful for testing against a scratch directory).
- Write and validate your package (see Packages & the manifest).
mixlar-sdk pack [--sign <key-id>]to produce a.mixplugin.mixlar-sdk verifyif signed, to confirm the signature checks out.mixlar-sdk link(orpackaging.install()) to drop it into the app’s plugins directory for local testing.
How this maps to the CLI
Section titled “How this maps to the CLI”| CLI command | mixlar.packaging function |
What it does |
|---|---|---|
mixlar-sdk pack |
pack() |
Validate, optionally sign, zip deterministically |
mixlar-sdk link / install flows |
install() (→ unpack()) |
Extract into the app’s plugins directory, guarded against path traversal |
See Pack, sign, publish for the command-line walkthrough, and Validate & emulate for checking a package before you pack it.