How the parts work together

How a plugin is found, loaded and offered

A plugin is a Rust dylib compiled against the same build of the shared library that Studio runs with. It carries one SDK Package and a small manifest beside it. At startup the app reads every manifest, decides what loads, opens each chosen library, checks that it was compiled against the running build, and installs its package in core's registry. Every menu, browser and dialog reads its offers from that registry. A package that is not installed leaves placeholders in the projects that use it.

On this page

The build: one kit, one id per compile

Studio and its plugins share one standard library and one build of the crates they exchange values through, so values cross as plain Rust types and TypeIds agree. .cargo/config.toml builds everything with -C prefer-dynamic, and release builds without LTO. kinogaki-shared (studio/core/shared/src/lib.rs) is a Rust dylib that re-exports the SDK, kinogaki-studio-api, together with its UI layer kinogaki-studio-ui-api, kinogaki-render-api, kinogaki-geometry, kinogaki-edit, kinogaki-viewport-api and wgpu. This build is the kit. The library enables every feature that any crate in the workspace enables on the crates it holds, so each build of it comes out the same; tools/check_architecture.py names any feature it lacks. The app, kinogaki-studio, links it with use kinogaki_shared as _; (studio/apps/studio/src/main.rs) and finds it by @rpath: in the bundle's Frameworks folder, or in the deps folder beside an executable in a build folder (studio/apps/studio/build.rs).

Three names tie a plugin to one compile of the kit:

A first-party plugin's dylib crate holds just these two macro calls (plugins/obj/plugin/src/lib.rs: link!(), then export_package!(kinogaki_obj::package)); its code lives in the library crate beside it (plugins/obj).

tools/build_plugins.py runs one cargo build --locked of kinogaki-shared and every crate in its PLUGINS table. Each row gives a kind folder, a file name, the dylib crate, the package id and the ids the package depends on. The script copies each library into the build folder's plugins folder as <kind>/<name>.dylib and writes <name>.prisma beside it. The manifest is a Prism text document: a plugin element with id, kind, version (the crate's version) and one depends child per dependency. The table lists fourteen plugins: the Mesh kernel, three node packages (Mesh, Layout, Comp), eight formats and two render engines (Spectre, Pixel). None of them declares a dependency.

--kit DIR also writes the kit for third parties: a deps folder (the shared dylib, the metadata of every crate it holds, the proc-macro libraries), a sysroot folder with the compiler's standard library, and a README that names the exact rustc and the command line. --examples DIR then builds the SDK's four example plugins with plain rustc against that kit alone. tools/package_macos.py makes the same build with the app added, copies the standard library and kinogaki-shared into the bundle's Frameworks folder and the plugins into its PlugIns folder (when there are any), refuses a binary that links anything outside @rpath and the system, re-signs ad hoc, and writes the kit beside the bundle.

with its .prisma BUILD recorded found ids admit unavailable unavailable build_plugins.pyone cargo build kinogaki-sharedthe kit, BUILD id Plugin dyliblink!, export_package! scanmanifests, nothing opened choice + resolveoff, one per id, deps first load + sysdlopen, BUILD check, entry registry::installthe SDK's Registry checks Settings ▸ Pluginsfound ids, on or off Consolewhy one didn't load

Where the app looks, and reading manifests

kinogaki-plugin-host is the loader. Folders::standard() (studio/core/plugin-host/src/scan.rs) finds two roots. The first is the install's folder: Install::running() treats an executable inside Contents/MacOS as a bundle and uses the bundle's PlugIns folder; any other executable uses the plugins folder beside it, which build_plugins.py fills. The second is the user's folder: on macOS, Application Support/Kinogaki Studio/plugins in the user's Library. sys.rs opens libraries on Unix systems and reports "plugins aren't supported on this platform yet" elsewhere.

scan::scan walks the install's root first, then the user's. Within each root it visits the kind folders in Kind::ALL order (kernels, nodes, formats, renders) and the files in name order. It reads each manifest with Manifest::read (studio/core/plugin-host/src/manifest.rs, over kinogaki-prism) and opens nothing. A library without a manifest, a manifest that doesn't read, a manifest without its library, or a manifest whose kind names another folder becomes an Unavailable with its Why.

Choosing what loads

kinogaki_editor::load_plugins (studio/core/editor/src/packages/plugins.rs, in kinogaki-editor) runs once per process. main.rs calls it after kinogaki_cook::workers::install() and kinogaki_core_package::install(); the second call installs core's own package, kinogaki.core from kinogaki-core-package, before any plugin's. The function first works out which ids to turn off:

resolve::resolve (studio/core/plugin-host/src/resolve.rs) then decides the order. For each id, the first plugin found is kept; later ones are Why::Shadowed, so the install's copy wins over the user's. Ids in the off set are Why::Off. The resolver repeatedly drops any plugin whose dependency is absent: NeedsMissing when that dependency was never found, NeedsUnavailable when it was found but can't load. It orders the rest in rounds. Each round takes, by id, every plugin whose dependencies are already taken, and whatever is left at the end is Why::Cycle. So the load order comes from the manifests' dependencies and the ids alone.

Opening a library and calling its entry

load::load (studio/core/plugin-host/src/load.rs) works through the chosen plugins in order:

  1. A plugin whose dependency failed to load is NeedsUnavailable.
  2. sys::open (studio/core/plugin-host/src/sys.rs) calls dlopen (RTLD_NOW | RTLD_LOCAL on macOS) and looks up the two symbols. It is the one module of Studio's core and app crates allowed unsafe outside tests (tools/architecture/libraries.toml). As its notes say, a Rust library has no initializers, so opening runs none of the plugin's code.
  3. If the kinogaki_shared_build record is missing, or differs from the BUILD of the library the app runs with, the plugin is Why::OtherBuild ("it was built against a different Kinogaki build; rebuild plugins") and its entry is never called. So a cargo build that rebuilds the app and kinogaki-shared without the plugins leaves them refused.
  4. The entry is called once inside catch_unwind. A panic is Why::Panicked, a missing entry is Why::Open, and a package whose id differs from the manifest's is Why::OtherId.
  5. The package goes to the host's admit. A refusal is Why::Refused with the registry's reason, and the plugins that need it become unavailable.

Libraries are never closed: their code may be running on any thread, and the vtables and statics they made live on.

Admission: the registry

The editor's admit is kinogaki_network::registry::install (studio/core/network/src/registry.rs, in kinogaki-network). If a package of the same id is already installed and declares the same ids, the call does nothing; any other package of that id is refused. Otherwise the SDK's Registry::register runs Registry::check (sdk/crates/api/src/registry/check.rs) first and keeps nothing on a refusal. The checks:

Once a package is admitted, its verbs are offered to the whole process (kernel::offer). Core's table keeps its contexts, its node types (each description read once), its held edits, data types and compilers, and rebuilds the lists of formats and engines in install order. The table only grows.

What a package brings, and where the user meets it

Every offer is read from the registry. Packages::new (studio/core/editor/src/packages/mod.rs) gives each installed package that has a UI its own state, in install order.

A package bringsRegistered withOffered through
Network contextswith_context (NetworkContext)the networks of objects whose kind names the context; the network pane
Node typeswith_node_type (NodeType)Tab in networks whose context offers them (registry::offers: Description::contexts, the context's adopts, Reach), under their listing's section; menus where a library names them
Node creationswith_creationTab, beside their node type (an engine's material presets)
Object kindswith_object_kind (ObjectKindSpec)their creations at the object levels: Tab, Create, the A marking menu, the right-click menu
Data types, held editswith_data_type, with_held_editvalues on wires and on the results board, saved through their codecs
File formatswith_format (FileFormat)File ▸ Import and Export, the types a File node reads, Settings ▸ Import
Render engineswith_engine (description and maker)a view's Render item, the Render menu, Settings ▸ Color, a Render Output's engine
Verbswith_verbany cook, by id: kernel::run, kernel::find
Stored valueswith_storedkept in the project, each view or the user's settings; edited on the package's settings pages (Settings ▸ Project for project values)
Session slotswith_slotundoable session state, never saved
UIWithUi::with_ui (UiPackage)each context's ContextPart and the package's settings pages

A package can also bring meshers, a script engine, compilers and runners for compiled contexts, and icons; the registry checks icons, and core draws none of a package's icons yet.

The four kinds

Kind (studio/core/plugin-host/src/manifest.rs) has four values: Kernels, Nodes, Formats and Renders. In code, a kind is the folder a plugin sits in and the kind its manifest states. The scan requires the two to agree, and Kind::ALL orders the folders within a root, which decides which of two plugins with the same id is found first. The load order comes from dependencies and ids, and the registry checks every package by the same rules whatever its kind. The kinds differ in what the first-party packages register:

When a plugin is missing

A package that didn't load is absent from the registry, so nothing offers what it brings. A project that uses it still opens and saves without loss (Kino files):

Written in the tree as docs/how/plugins.md.

← Saving and loading a .kino