docs/plugins: detail GUI plugin usage; separate server & GUI plugins
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I2060db637209655390a86facd004bc646a6a6964
This commit is contained in:
parent
ad6d0b646f
commit
be72b6a7ed
1 changed files with 471 additions and 60 deletions
531
docs/plugins.md
531
docs/plugins.md
|
|
@ -1,17 +1,30 @@
|
||||||
# Plugin System
|
# Plugin System
|
||||||
|
|
||||||
Pinakes is very powerful on its own, but with a goal as ambitious as "to be the
|
Pinakes is first and foremost a _server_ application. This server can be
|
||||||
last media management system you will ever need" I recognize the need for a
|
extended with a plugin system that runs WASM binaries in the server process.
|
||||||
plugin system; not everything belongs in the core of Pinakes. Thus, Pinakes
|
They extend media type detection, metadata extension, thumbnail generation,
|
||||||
supports WASM-based plugins for extending media type support, metadata
|
search, event handling and theming.
|
||||||
extraction, thumbnail generation, search, event handling, and theming.
|
|
||||||
|
|
||||||
Plugins run in a sandboxed wasmtime runtime with capability-based security, fuel
|
The first-party GUI for Pinakes, dubbed `pinakes-ui` within this codebase, can
|
||||||
metering, memory limits, and a circuit breaker for fault isolation.
|
also be extended through a separate plugin system. GUI plugins add pages and
|
||||||
|
widgets to the desktop/web interface through declarative JSON schemas. No WASM
|
||||||
|
code runs during rendering.
|
||||||
|
|
||||||
## How It Works
|
> [!NOTE]
|
||||||
|
> While mostly functional, the plugin system is **experimental**. It might
|
||||||
|
> change at any given time, without notice and without any effort for backwards
|
||||||
|
> compatibility. Please provide any feedback that you might have!
|
||||||
|
|
||||||
A plugin is a directory containing:
|
## Server Plugins
|
||||||
|
|
||||||
|
Server plugins run in a sandboxed wasmtime runtime with capability-based
|
||||||
|
security, fuel metering, memory limits, and a circuit breaker for fault
|
||||||
|
isolation.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
A plugin is a directory containing a WASM binary and a plugin manifest. Usually
|
||||||
|
in this format:
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
my-plugin/
|
my-plugin/
|
||||||
|
|
@ -19,9 +32,9 @@ my-plugin/
|
||||||
my_plugin.wasm Compiled WASM binary (wasm32-unknown-unknown target)
|
my_plugin.wasm Compiled WASM binary (wasm32-unknown-unknown target)
|
||||||
```
|
```
|
||||||
|
|
||||||
The server discovers plugins in configured directories, validates their
|
The server discovers plugins from configured directories, validates their
|
||||||
manifests, checks capabilities against the security policy, compiles the WASM
|
manifests, checks capabilities against the security policy, compiles the WASM
|
||||||
module, and registers the plugin.
|
module and registers the plugin.
|
||||||
|
|
||||||
Extension points communicate via JSON-over-WASM. The host writes a JSON request
|
Extension points communicate via JSON-over-WASM. The host writes a JSON request
|
||||||
into the plugin's memory, calls the exported function, and reads the JSON
|
into the plugin's memory, calls the exported function, and reads the JSON
|
||||||
|
|
@ -32,7 +45,7 @@ introduces a little bit of overhead, it's the more debuggable approach and thus
|
||||||
more suitable for the initial plugin system. In the future, this might change.
|
more suitable for the initial plugin system. In the future, this might change.
|
||||||
|
|
||||||
Plugins go through a priority-ordered pipeline. Each plugin declares a priority
|
Plugins go through a priority-ordered pipeline. Each plugin declares a priority
|
||||||
(0–999, default 500). Built-in handlers run at implicit priority 100, so plugins
|
(0-999, default 500). Built-in handlers run at implicit priority 100, so plugins
|
||||||
at priority <100 run _before_ built-ins and plugins at >100 run _after_.
|
at priority <100 run _before_ built-ins and plugins at >100 run _after_.
|
||||||
Different extension points use different merge strategies:
|
Different extension points use different merge strategies:
|
||||||
|
|
||||||
|
|
@ -49,7 +62,7 @@ Different extension points use different merge strategies:
|
||||||
|
|
||||||
<!--markdownlint-enable MD013-->
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
## Plugin Kinds
|
### Plugin Kinds
|
||||||
|
|
||||||
A plugin can implement one or more of these roles:
|
A plugin can implement one or more of these roles:
|
||||||
|
|
||||||
|
|
@ -74,7 +87,7 @@ kind = ["media_type", "metadata_extractor", "thumbnail_generator"]
|
||||||
priority = 50
|
priority = 50
|
||||||
```
|
```
|
||||||
|
|
||||||
## Writing a Plugin
|
### Writing a Plugin
|
||||||
|
|
||||||
[dlmalloc]: https://crates.io/crates/dlmalloc
|
[dlmalloc]: https://crates.io/crates/dlmalloc
|
||||||
|
|
||||||
|
|
@ -99,6 +112,8 @@ lto = true
|
||||||
|
|
||||||
Let's go over a minimal example that registers a custom `.mytype` file format:
|
Let's go over a minimal example that registers a custom `.mytype` file format:
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
|
@ -156,34 +171,39 @@ pub extern "C" fn can_handle(ptr: i32, len: i32) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
#### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Build for the wasm32-unknown-unknown target
|
||||||
$ cargo build --target wasm32-unknown-unknown --release
|
$ cargo build --target wasm32-unknown-unknown --release
|
||||||
```
|
```
|
||||||
|
|
||||||
The `RUSTFLAGS=""` override may be needed if your environment sets linker flags
|
The `RUSTFLAGS=""` override may be needed if your environment sets linker flags
|
||||||
(e.g., `-fuse-ld=lld`) that are incompatible with the WASM target. You can also
|
(e.g., `-fuse-ld=lld`) that are incompatible with the WASM target. You can also
|
||||||
specify a compatible linker explicitly. As pinakes uses a `clang` and `lld`
|
specify a compatible linker explicitly. As pinakes uses a `clang` and `lld`
|
||||||
based pipeline, it's necessary to set for, e.g., the test fixtures.
|
based pipeline, it's necessary to set for, e.g., the test fixtures while
|
||||||
|
building inside the codebase. In most cases, you will not need it.
|
||||||
|
|
||||||
The compiled binary will be at
|
Once the compilation is done, the resulting binary will be in the target
|
||||||
`target/wasm32-unknown-unknown/release/my_plugin.wasm`.
|
directory. More specifically it will be in
|
||||||
|
`target/wasm32-unknown-unknown/release` but the path changes based on your
|
||||||
|
target, and the build mode (dev/release).
|
||||||
|
|
||||||
### A note on `serde` in `no_std`
|
> [!NOTE]
|
||||||
|
> Since `serde_json` requires `std`, you cannot use it in a plugin. Either
|
||||||
|
> hand-write JSON strings (as shown above) or use a lightweight no_std JSON
|
||||||
|
> library.
|
||||||
|
>
|
||||||
|
> The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/`
|
||||||
|
> demonstrates the hand-written approach. It is ugly, but it works, and the
|
||||||
|
> binaries are tiny (~17KB). You are advised to replace this in your plugins.
|
||||||
|
|
||||||
Since `serde_json` requires `std`, you cannot use it in a plugin. Either
|
#### Installing
|
||||||
hand-write JSON strings (as shown above) or use a lightweight no_std JSON
|
|
||||||
library.
|
|
||||||
|
|
||||||
The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/`
|
|
||||||
demonstrates the hand-written approach. It is ugly, but it works, and the
|
|
||||||
binaries are tiny (~17KB).
|
|
||||||
|
|
||||||
### Installing
|
|
||||||
|
|
||||||
Place the plugin directory in one of the configured plugin directories, or use
|
Place the plugin directory in one of the configured plugin directories, or use
|
||||||
the API:
|
the API to load them while the server is running.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/v1/plugins/install \
|
curl -X POST http://localhost:3000/api/v1/plugins/install \
|
||||||
|
|
@ -192,10 +212,10 @@ curl -X POST http://localhost:3000/api/v1/plugins/install \
|
||||||
-d '{"source": "/path/to/my-plugin"}'
|
-d '{"source": "/path/to/my-plugin"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manifest Reference
|
### Manifest Reference
|
||||||
|
|
||||||
Plugins are required to provide a manifest with explicit intents for everything.
|
Plugins are required to provide a manifest with explicit intents for everything.
|
||||||
Here is the expected manifest format as of 0.3.0-dev version of Pinakes:
|
Here is the expected manifest format as of **0.3.0-dev version** of Pinakes:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[plugin]
|
[plugin]
|
||||||
|
|
@ -230,7 +250,7 @@ max_memory_mb = 64 # Maximum linear memory (default: 512MB)
|
||||||
max_cpu_time_secs = 5 # Fuel budget per invocation (default: 60s)
|
max_cpu_time_secs = 5 # Fuel budget per invocation (default: 60s)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extension Points
|
### Extension Points
|
||||||
|
|
||||||
Every plugin must export these three functions:
|
Every plugin must export these three functions:
|
||||||
|
|
||||||
|
|
@ -244,7 +264,7 @@ Beyond those, which functions you export depends on your plugin's kind(s). The
|
||||||
capability enforcer prevents a plugin from being called for functions it hasn't
|
capability enforcer prevents a plugin from being called for functions it hasn't
|
||||||
declared; a `metadata_extractor` plugin cannot have `search` called on it.
|
declared; a `metadata_extractor` plugin cannot have `search` called on it.
|
||||||
|
|
||||||
### `MediaTypeProvider`
|
#### `MediaTypeProvider`
|
||||||
|
|
||||||
Plugins with `kind` containing `"media_type"` export:
|
Plugins with `kind` containing `"media_type"` export:
|
||||||
|
|
||||||
|
|
@ -272,7 +292,7 @@ In the pipeline first-match-wins. Plugins are checked in priority order. The
|
||||||
first where `can_handle` returns `true` and has a matching definition claims the
|
first where `can_handle` returns `true` and has a matching definition claims the
|
||||||
file. If no plugin claims it, built-in handlers run as fallback.
|
file. If no plugin claims it, built-in handlers run as fallback.
|
||||||
|
|
||||||
### `MetadataExtractor`
|
#### `MetadataExtractor`
|
||||||
|
|
||||||
Plugins with `kind` containing `"metadata_extractor"` export:
|
Plugins with `kind` containing `"metadata_extractor"` export:
|
||||||
|
|
||||||
|
|
@ -301,7 +321,7 @@ The pipeline features accumulating merge. All matching plugins run in priority
|
||||||
order. Later plugins' non-null fields overwrite earlier ones. The `extra` maps
|
order. Later plugins' non-null fields overwrite earlier ones. The `extra` maps
|
||||||
are merged (later keys win).
|
are merged (later keys win).
|
||||||
|
|
||||||
### `ThumbnailGenerator`
|
#### `ThumbnailGenerator`
|
||||||
|
|
||||||
Plugins with `kind` containing `"thumbnail_generator"` export:
|
Plugins with `kind` containing `"thumbnail_generator"` export:
|
||||||
|
|
||||||
|
|
@ -329,7 +349,7 @@ Same as [MetadataExtractor](#metadataextractor)
|
||||||
First-success-wins. The first plugin to return a successful result produces the
|
First-success-wins. The first plugin to return a successful result produces the
|
||||||
thumbnail.
|
thumbnail.
|
||||||
|
|
||||||
### `SearchBackend`
|
#### `SearchBackend`
|
||||||
|
|
||||||
Plugins with `kind` containing `"search_backend"` export:
|
Plugins with `kind` containing `"search_backend"` export:
|
||||||
|
|
||||||
|
|
@ -371,7 +391,7 @@ Results from all backends are merged, deduplicated by ID (keeping the highest
|
||||||
score), and sorted by score descending. `index_item` and `remove_item` are
|
score), and sorted by score descending. `index_item` and `remove_item` are
|
||||||
fanned out to all backends.
|
fanned out to all backends.
|
||||||
|
|
||||||
### `EventHandler`
|
#### `EventHandler`
|
||||||
|
|
||||||
Plugins with `kind` containing `"event_handler"` export:
|
Plugins with `kind` containing `"event_handler"` export:
|
||||||
|
|
||||||
|
|
@ -394,9 +414,9 @@ Plugins with `kind` containing `"event_handler"` export:
|
||||||
|
|
||||||
All interested plugins receive the event. Events are dispatched asynchronously
|
All interested plugins receive the event. Events are dispatched asynchronously
|
||||||
via `tokio::spawn` and do not block the caller. Handler failures are logged but
|
via `tokio::spawn` and do not block the caller. Handler failures are logged but
|
||||||
never propagated. Suffice to say that the pipeline is fan-out.
|
never propagated. The pipeline is fan-out.
|
||||||
|
|
||||||
### `ThemeProvider`
|
#### `ThemeProvider`
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> `ThemeProvider` is experimental, and will likely be subject to change.
|
> `ThemeProvider` is experimental, and will likely be subject to change.
|
||||||
|
|
@ -431,7 +451,7 @@ Plugins with `kind` containing `"theme_provider"` export:
|
||||||
Themes from all providers are accumulated. When loading a specific theme, the
|
Themes from all providers are accumulated. When loading a specific theme, the
|
||||||
pipeline dispatches to the plugin that registered it.
|
pipeline dispatches to the plugin that registered it.
|
||||||
|
|
||||||
### System Events
|
#### System Events
|
||||||
|
|
||||||
The server emits these events at various points:
|
The server emits these events at various points:
|
||||||
|
|
||||||
|
|
@ -450,45 +470,45 @@ The server emits these events at various points:
|
||||||
Plugins can also emit events themselves via `host_emit_event`, enabling
|
Plugins can also emit events themselves via `host_emit_event`, enabling
|
||||||
plugin-to-plugin communication.
|
plugin-to-plugin communication.
|
||||||
|
|
||||||
## Host Functions
|
### Host Functions
|
||||||
|
|
||||||
Plugins can call these host functions from within WASM (imported from the
|
Plugins can call these host functions from within WASM (imported from the
|
||||||
`"env"` module):
|
`"env"` module):
|
||||||
|
|
||||||
### `host_set_result(ptr, len)`
|
#### `host_set_result(ptr, len)`
|
||||||
|
|
||||||
Write a JSON response back to the host. The plugin writes the response bytes
|
Write a JSON response back to the host. The plugin writes the response bytes
|
||||||
into its own linear memory and passes the pointer and length. This is how you
|
into its own linear memory and passes the pointer and length. This is how you
|
||||||
return data from any extension point function.
|
return data from any extension point function.
|
||||||
|
|
||||||
### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32`
|
#### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32`
|
||||||
|
|
||||||
Emit a system event from within a plugin. Enables plugin-to-plugin
|
Emit a system event from within a plugin. Enables plugin-to-plugin
|
||||||
communication.
|
communication.
|
||||||
|
|
||||||
Returns `0` on success, `-1` on error.
|
Returns `0` on success, `-1` on error.
|
||||||
|
|
||||||
### `host_log(level, ptr, len)`
|
#### `host_log(level, ptr, len)`
|
||||||
|
|
||||||
Log a message.
|
Log a message.
|
||||||
|
|
||||||
Levels: 0=error, 1=warn, 2=info, 3+=debug. Messages appear in the server's
|
Levels: 0=error, 1=warn, 2=info, 3+=debug. Messages appear in the server's
|
||||||
tracing output.
|
tracing output.
|
||||||
|
|
||||||
### `host_read_file(path_ptr, path_len) -> i32`
|
#### `host_read_file(path_ptr, path_len) -> i32`
|
||||||
|
|
||||||
Read a file into the exchange buffer. Returns file size on success, `-1` on IO
|
Read a file into the exchange buffer. Returns file size on success, `-1` on IO
|
||||||
error, `-2` if the path is outside the plugin's allowed read paths (paths are
|
error, `-2` if the path is outside the plugin's allowed read paths (paths are
|
||||||
canonicalized before checking, so traversal tricks won't work).
|
canonicalized before checking, so traversal tricks won't work).
|
||||||
|
|
||||||
### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32`
|
#### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32`
|
||||||
|
|
||||||
Write data to a file.
|
Write data to a file.
|
||||||
|
|
||||||
Returns `0` on success, `-1` on IO error, `-2` if the path is outside allowed
|
Returns `0` on success, `-1` on IO error, `-2` if the path is outside allowed
|
||||||
write paths.
|
write paths.
|
||||||
|
|
||||||
### `host_http_request(url_ptr, url_len) -> i32`
|
#### `host_http_request(url_ptr, url_len) -> i32`
|
||||||
|
|
||||||
Make an HTTP GET request.
|
Make an HTTP GET request.
|
||||||
|
|
||||||
|
|
@ -496,12 +516,12 @@ Returns response size on success (body in exchange buffer), `-1` on error, `-2`
|
||||||
if network access is disabled, `-3` if the domain is not in the plugin's
|
if network access is disabled, `-3` if the domain is not in the plugin's
|
||||||
`allowed_domains` list.
|
`allowed_domains` list.
|
||||||
|
|
||||||
### `host_get_config(key_ptr, key_len) -> i32`
|
#### `host_get_config(key_ptr, key_len) -> i32`
|
||||||
|
|
||||||
Read a plugin configuration value. Returns JSON value length on success (in
|
Read a plugin configuration value. Returns JSON value length on success (in
|
||||||
exchange buffer), `-1` if key not found.
|
exchange buffer), `-1` if key not found.
|
||||||
|
|
||||||
### `host_get_env(key_ptr, key_len) -> i32`
|
#### `host_get_env(key_ptr, key_len) -> i32`
|
||||||
|
|
||||||
Read an environment variable.
|
Read an environment variable.
|
||||||
|
|
||||||
|
|
@ -509,7 +529,7 @@ Returns value length on success (in exchange buffer), `-1` if the variable is
|
||||||
not set, `-2` if environment access is disabled or the variable is not in the
|
not set, `-2` if environment access is disabled or the variable is not in the
|
||||||
plugin's `environment` list.
|
plugin's `environment` list.
|
||||||
|
|
||||||
### `host_get_buffer(dest_ptr, dest_len) -> i32`
|
#### `host_get_buffer(dest_ptr, dest_len) -> i32`
|
||||||
|
|
||||||
Copy the exchange buffer into WASM memory.
|
Copy the exchange buffer into WASM memory.
|
||||||
|
|
||||||
|
|
@ -517,9 +537,9 @@ Returns bytes copied. Use this after `host_read_file`, `host_http_request`,
|
||||||
`host_get_config`, or `host_get_env` to retrieve data the host placed in the
|
`host_get_config`, or `host_get_env` to retrieve data the host placed in the
|
||||||
buffer.
|
buffer.
|
||||||
|
|
||||||
## Security Model
|
### Security Model
|
||||||
|
|
||||||
### Capabilities
|
#### Capabilities
|
||||||
|
|
||||||
Every plugin declares what it needs in `plugin.toml`. The `CapabilityEnforcer`
|
Every plugin declares what it needs in `plugin.toml`. The `CapabilityEnforcer`
|
||||||
validates these at load time _and_ at runtime; a plugin declaring
|
validates these at load time _and_ at runtime; a plugin declaring
|
||||||
|
|
@ -545,7 +565,7 @@ without `network = true` will get `-2` from `host_http_request`.
|
||||||
- **CPU**: Enforced via wasmtime's fuel metering. Each invocation gets a fuel
|
- **CPU**: Enforced via wasmtime's fuel metering. Each invocation gets a fuel
|
||||||
budget proportional to the configured CPU time limit.
|
budget proportional to the configured CPU time limit.
|
||||||
|
|
||||||
### Isolation
|
#### Isolation
|
||||||
|
|
||||||
- Each `call_function` invocation creates a fresh wasmtime `Store`. Plugins
|
- Each `call_function` invocation creates a fresh wasmtime `Store`. Plugins
|
||||||
cannot retain WASM runtime state between calls. If you need persistence, use
|
cannot retain WASM runtime state between calls. If you need persistence, use
|
||||||
|
|
@ -555,7 +575,7 @@ without `network = true` will get `-2` from `host_http_request`.
|
||||||
|
|
||||||
- The wasmtime stack is limited to 1MB.
|
- The wasmtime stack is limited to 1MB.
|
||||||
|
|
||||||
### Timeouts
|
#### Timeouts
|
||||||
|
|
||||||
Three tiers of per-call timeouts prevent runaway plugins:
|
Three tiers of per-call timeouts prevent runaway plugins:
|
||||||
|
|
||||||
|
|
@ -569,7 +589,7 @@ Three tiers of per-call timeouts prevent runaway plugins:
|
||||||
|
|
||||||
<!--markdownlint-enable MD013-->
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
### Circuit Breaker
|
#### Circuit Breaker
|
||||||
|
|
||||||
If a plugin fails consecutively, the circuit breaker disables it automatically:
|
If a plugin fails consecutively, the circuit breaker disables it automatically:
|
||||||
|
|
||||||
|
|
@ -579,7 +599,7 @@ If a plugin fails consecutively, the circuit breaker disables it automatically:
|
||||||
- Disabled plugins are skipped in all pipeline stages.
|
- Disabled plugins are skipped in all pipeline stages.
|
||||||
- Reload or toggle the plugin via the API to re-enable.
|
- Reload or toggle the plugin via the API to re-enable.
|
||||||
|
|
||||||
### Signatures
|
#### Signatures
|
||||||
|
|
||||||
Plugins can be signed with Ed25519. The signing flow:
|
Plugins can be signed with Ed25519. The signing flow:
|
||||||
|
|
||||||
|
|
@ -603,7 +623,7 @@ verifies against at least one trusted key. If the signature is missing, invalid,
|
||||||
or matches no trusted key, the plugin is rejected at load time. Set
|
or matches no trusted key, the plugin is rejected at load time. Set
|
||||||
`allow_unsigned = true` during development to skip this check.
|
`allow_unsigned = true` during development to skip this check.
|
||||||
|
|
||||||
## Plugin Lifecycle
|
### Plugin Lifecycle
|
||||||
|
|
||||||
1. **Discovery**: On startup, the plugin manager walks configured plugin
|
1. **Discovery**: On startup, the plugin manager walks configured plugin
|
||||||
directories looking for `plugin.toml` files.
|
directories looking for `plugin.toml` files.
|
||||||
|
|
@ -637,7 +657,7 @@ or matches no trusted key, the plugin is rejected at load time. Set
|
||||||
9. **Hot-reload**: The `/plugins/:id/reload` endpoint reloads the WASM binary
|
9. **Hot-reload**: The `/plugins/:id/reload` endpoint reloads the WASM binary
|
||||||
from disk and re-discovers capabilities without restarting the server.
|
from disk and re-discovers capabilities without restarting the server.
|
||||||
|
|
||||||
## Plugin API Endpoints
|
### Plugin API Endpoints
|
||||||
|
|
||||||
<!-- FIXME: this should be moved to API documentation -->
|
<!-- FIXME: this should be moved to API documentation -->
|
||||||
|
|
||||||
|
|
@ -657,7 +677,7 @@ Reload and toggle operations automatically re-discover the plugin's
|
||||||
capabilities, so changes to supported types or event subscriptions take effect
|
capabilities, so changes to supported types or event subscriptions take effect
|
||||||
immediately.
|
immediately.
|
||||||
|
|
||||||
## Configuration
|
### Configuration
|
||||||
|
|
||||||
In `pinakes.toml`:
|
In `pinakes.toml`:
|
||||||
|
|
||||||
|
|
@ -682,7 +702,7 @@ allowed_read_paths = ["/media", "/tmp/pinakes"]
|
||||||
allowed_write_paths = ["/tmp/pinakes/plugin-data"]
|
allowed_write_paths = ["/tmp/pinakes/plugin-data"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debugging
|
### Debugging
|
||||||
|
|
||||||
- **`host_log`**: Call from within your plugin to emit structured log messages.
|
- **`host_log`**: Call from within your plugin to emit structured log messages.
|
||||||
They appear in the server's tracing output.
|
They appear in the server's tracing output.
|
||||||
|
|
@ -693,3 +713,394 @@ allowed_write_paths = ["/tmp/pinakes/plugin-data"]
|
||||||
- **Circuit breaker**: If your plugin is silently skipped, check the server logs
|
- **Circuit breaker**: If your plugin is silently skipped, check the server logs
|
||||||
for "circuit breaker tripped" messages. Fix the issue, then re-enable via
|
for "circuit breaker tripped" messages. Fix the issue, then re-enable via
|
||||||
`POST /api/v1/plugins/:id/enable`.
|
`POST /api/v1/plugins/:id/enable`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GUI Plugins
|
||||||
|
|
||||||
|
A plugin declares its UI pages and optional widgets in `plugin.toml`, either
|
||||||
|
inline or as separate `.json` files. At startup, the server indexes all plugin
|
||||||
|
manifests and serves the schemas from `GET /api/v1/plugins/ui/pages`. The UI
|
||||||
|
fetches and validates these schemas, registers them in the `PluginRegistry`, and
|
||||||
|
adds the pages to the sidebar navigation. Widgets are injected into fixed
|
||||||
|
locations in host views at registration time.
|
||||||
|
|
||||||
|
No WASM code runs during page rendering. The schema is purely declarative JSON.
|
||||||
|
|
||||||
|
### Manifest Additions
|
||||||
|
|
||||||
|
Add a `[ui]` section to `plugin.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugin]
|
||||||
|
name = "my-plugin"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
# Inline page definition
|
||||||
|
[[ui.pages]]
|
||||||
|
id = "stats"
|
||||||
|
title = "My Stats"
|
||||||
|
route = "/plugins/my-plugin/stats"
|
||||||
|
icon = "chart-bar"
|
||||||
|
|
||||||
|
[ui.pages.data_sources.summary]
|
||||||
|
type = "endpoint"
|
||||||
|
path = "/api/v1/plugins/my-plugin/summary"
|
||||||
|
poll_interval = 30 # re-fetch every 30 seconds
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
children = []
|
||||||
|
|
||||||
|
# File-referenced page
|
||||||
|
[[ui.pages]]
|
||||||
|
file = "pages/detail.json" # path relative to plugin directory
|
||||||
|
|
||||||
|
# Widget injections
|
||||||
|
[[ui.widgets]]
|
||||||
|
id = "my-badge"
|
||||||
|
target = "library_header"
|
||||||
|
|
||||||
|
[ui.widgets.content]
|
||||||
|
type = "badge"
|
||||||
|
text = "My Plugin"
|
||||||
|
variant = "default"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, pages can be defined entirely in a separate JSON file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "stats",
|
||||||
|
"title": "My Stats",
|
||||||
|
"route": "/plugins/my-plugin/stats",
|
||||||
|
"icon": "chart-bar",
|
||||||
|
"data_sources": {
|
||||||
|
"summary": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "/api/v1/plugins/my-plugin/summary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"type": "container",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `UiPage` Fields
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| -------------- | -------------------------- | -------- | ----------------------------------------------------- |
|
||||||
|
| `id` | string | Yes | Unique identifier (alphanumeric, dashes, underscores) |
|
||||||
|
| `title` | string | Yes | Display name shown in navigation |
|
||||||
|
| `route` | string | Yes | URL path (must start with `/`) |
|
||||||
|
| `icon` | string | No | Icon name (from dioxus-free-icons) |
|
||||||
|
| `layout` | `UiElement` | Yes | Root layout element (see Element Reference) |
|
||||||
|
| `data_sources` | `{name: DataSource}` | No | Named data sources available to this page |
|
||||||
|
| `actions` | `{name: ActionDefinition}` | No | Named actions referenced by elements |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Data Sources
|
||||||
|
|
||||||
|
Data sources populate named slots that elements bind to via their `data` field.
|
||||||
|
|
||||||
|
#### `endpoint`: HTTP API call
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ui.pages.data_sources.items]
|
||||||
|
type = "endpoint"
|
||||||
|
method = "GET" # GET (default), POST, PUT, PATCH, DELETE
|
||||||
|
path = "/api/v1/media" # must start with /
|
||||||
|
poll_interval = 60 # seconds; 0 = no polling (default)
|
||||||
|
|
||||||
|
# Query params for GET, body for other methods
|
||||||
|
#
|
||||||
|
# Values are Expressions (see Expression Syntax)
|
||||||
|
[ui.pages.data_sources.items.params]
|
||||||
|
limit = 20
|
||||||
|
offset = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `static`: Inline JSON
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ui.pages.data_sources.options]
|
||||||
|
type = "static"
|
||||||
|
value = ["asc", "desc"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `transform`: Derived from another source
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Evaluate an expression against an already-fetched source
|
||||||
|
[ui.pages.data_sources.count]
|
||||||
|
type = "transform"
|
||||||
|
source = "items" # name of the source to read from
|
||||||
|
expression = "items.total_count" # path expression into the context
|
||||||
|
```
|
||||||
|
|
||||||
|
Transform sources always run after all non-transform sources, so the named
|
||||||
|
source is guaranteed to be available in the context.
|
||||||
|
|
||||||
|
### Expression Syntax
|
||||||
|
|
||||||
|
Expressions appear as values in `params`, `transform`, `TextContent`, and
|
||||||
|
`Progress.value`.
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Form | JSON example | Description |
|
||||||
|
| --------- | --------------------------------------------------- | -------------------------------------------- |
|
||||||
|
| Literal | `42`, `"hello"`, `true`, `null` | A fixed JSON value |
|
||||||
|
| Path | `"users.0.name"`, `"summary.count"` | Dot-separated path into the data context |
|
||||||
|
| Operation | `{"left":"a","op":"concat","right":"b"}` | Binary expression (see operators table) |
|
||||||
|
| Call | `{"function":"format","args":["Hello, {}!","Bob"]}` | Built-in function call (see functions table) |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
Path expressions use dot notation. Array indices are plain numbers:
|
||||||
|
`"items.0.title"` accesses `items[0].title`.
|
||||||
|
|
||||||
|
**Operators:**
|
||||||
|
|
||||||
|
| `op` | Result type | Description |
|
||||||
|
| -------- | ----------- | ------------------------------------------ |
|
||||||
|
| `eq` | bool | Equal |
|
||||||
|
| `ne` | bool | Not equal |
|
||||||
|
| `gt` | bool | Greater than (numeric) |
|
||||||
|
| `gte` | bool | Greater than or equal |
|
||||||
|
| `lt` | bool | Less than |
|
||||||
|
| `lte` | bool | Less than or equal |
|
||||||
|
| `and` | bool | Logical AND |
|
||||||
|
| `or` | bool | Logical OR |
|
||||||
|
| `concat` | string | String concatenation (both sides coerced) |
|
||||||
|
| `add` | number | f64 addition |
|
||||||
|
| `sub` | number | f64 subtraction |
|
||||||
|
| `mul` | number | f64 multiplication |
|
||||||
|
| `div` | number | f64 division (returns 0 on divide-by-zero) |
|
||||||
|
|
||||||
|
**Built-in functions:**
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
| ----------- | -------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `len` | `len(value)` | Length of array, string (chars), or object (key count) |
|
||||||
|
| `upper` | `upper(str)` | Uppercase string |
|
||||||
|
| `lower` | `lower(str)` | Lowercase string |
|
||||||
|
| `trim` | `trim(str)` | Remove leading/trailing whitespace |
|
||||||
|
| `format` | `format(template, ...args)` | Replace `{}` placeholders left-to-right with args |
|
||||||
|
| `join` | `join(array, sep)` | Join array elements into a string with separator |
|
||||||
|
| `contains` | `contains(haystack, needle)` | True if string contains substring, or array contains value |
|
||||||
|
| `keys` | `keys(object)` | Array of object keys |
|
||||||
|
| `values` | `values(object)` | Array of object values |
|
||||||
|
| `abs` | `abs(number)` | Absolute value |
|
||||||
|
| `round` | `round(number)` | Round to nearest integer |
|
||||||
|
| `floor` | `floor(number)` | Round down |
|
||||||
|
| `ceil` | `ceil(number)` | Round up |
|
||||||
|
| `not` | `not(bool)` | Boolean negation |
|
||||||
|
| `coalesce` | `coalesce(a, b, ...)` | First non-null argument |
|
||||||
|
| `to_string` | `to_string(value)` | Convert any value to its display string |
|
||||||
|
| `to_number` | `to_number(value)` | Parse string to f64; pass through numbers; bool coerces to 0/1 |
|
||||||
|
| `slice` | `slice(array_or_string, start[, end])` | Sub-array or substring; negative indices count from end |
|
||||||
|
| `reverse` | `reverse(array_or_string)` | Reversed array or string |
|
||||||
|
| `if` | `if(cond, then, else)` | Return `then` if `cond` is true, otherwise `else` |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
Actions define what happens when a button is clicked or a form is submitted.
|
||||||
|
|
||||||
|
#### Inline action
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"label": "Delete",
|
||||||
|
"action": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/media/123",
|
||||||
|
"success_message": "Deleted!",
|
||||||
|
"navigate_to": "/library"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Named action
|
||||||
|
|
||||||
|
Define the action in the page's `actions` map, then reference it by name:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ui.pages.actions.delete-item]
|
||||||
|
method = "DELETE"
|
||||||
|
path = "/api/v1/media/target"
|
||||||
|
navigate_to = "/library"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference it anywhere an `ActionRef` is accepted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "button", "label": "Delete", "action": "delete-item" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Named actions allow multiple elements to share the same action definition
|
||||||
|
without repetition. The action name must be a valid identifier (alphanumeric,
|
||||||
|
dashes, underscores).
|
||||||
|
|
||||||
|
#### `ActionDefinition` fields
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
| ----------------- | ------ | ------- | ------------------------------------------ |
|
||||||
|
| `method` | string | `GET` | HTTP method |
|
||||||
|
| `path` | string | | API path (must start with `/`) |
|
||||||
|
| `params` | object | `{}` | Fixed params merged with form data on POST |
|
||||||
|
| `success_message` | string | | Toast message on success |
|
||||||
|
| `error_message` | string | | Toast message on error |
|
||||||
|
| `navigate_to` | string | | Route to navigate to after success |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Element Reference
|
||||||
|
|
||||||
|
All elements are JSON objects with a `type` field.
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Type | Key fields | Description |
|
||||||
|
| ------------------ | ----------------------------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| `container` | `children`, `gap`, `padding` | Stacked children with gap/padding |
|
||||||
|
| `grid` | `children`, `columns` (1-12), `gap` | CSS grid layout |
|
||||||
|
| `flex` | `children`, `direction`, `justify`, `align`, `gap`, `wrap` | Flexbox layout |
|
||||||
|
| `split` | `sidebar`, `sidebar_width`, `main` | Sidebar + main content layout |
|
||||||
|
| `tabs` | `tabs` (array of `{id,label,content[]}`), `default_tab` | Tabbed panels |
|
||||||
|
| `heading` | `level` (1-6), `content` | Section heading h1-h6 |
|
||||||
|
| `text` | `content`, `variant`, `allow_html` | Paragraph text |
|
||||||
|
| `code` | `content`, `language`, `show_line_numbers` | Code block with syntax highlighting |
|
||||||
|
| `data_table` | `columns`, `data`, `sortable`, `filterable`, `page_size`, `row_actions` | Sortable, filterable data table |
|
||||||
|
| `card` | `title`, `content`, `footer` | Content card with optional header/footer |
|
||||||
|
| `media_grid` | `data`, `columns`, `gap` | Responsive image/video grid |
|
||||||
|
| `list` | `data`, `item_template`, `dividers` | Templated list (loops over data items) |
|
||||||
|
| `description_list` | `data`, `horizontal` | Key-value pair list (metadata display) |
|
||||||
|
| `button` | `label`, `variant`, `action`, `disabled` | Clickable button |
|
||||||
|
| `form` | `fields`, `submit_label`, `submit_action`, `cancel_label` | Input form with submission |
|
||||||
|
| `link` | `text`, `href`, `external` | Navigation link |
|
||||||
|
| `progress` | `value` (Expression), `max`, `show_percentage` | Progress bar |
|
||||||
|
| `badge` | `text`, `variant` | Status badge/chip |
|
||||||
|
| `loop` | `data`, `item`, `template` | Iterates data array, renders template |
|
||||||
|
| `conditional` | `condition` (Expression), `then`, `else` | Conditional rendering |
|
||||||
|
| `chart` | `chart_type`, `data`, `x_key`, `y_key`, `title` | Bar/line/pie/scatter chart |
|
||||||
|
| `image` | `src`, `alt`, `width`, `height`, `object_fit` | Image element |
|
||||||
|
| `divider` | | Horizontal rule |
|
||||||
|
| `spacer` | `size` | Blank vertical space |
|
||||||
|
| `raw_html` | `html` | Sanitized raw HTML block |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Widget Injection
|
||||||
|
|
||||||
|
Widgets are small UI elements injected into fixed locations in the host views.
|
||||||
|
Unlike pages, widgets have no data sources; they render with static or
|
||||||
|
expression-based content only.
|
||||||
|
|
||||||
|
#### Declaring a widget
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[ui.widgets]]
|
||||||
|
id = "status-badge"
|
||||||
|
target = "library_header"
|
||||||
|
|
||||||
|
[ui.widgets.content]
|
||||||
|
type = "badge"
|
||||||
|
text = "My Plugin Active"
|
||||||
|
variant = "success"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Target locations
|
||||||
|
|
||||||
|
| Target string | Where it renders |
|
||||||
|
| ----------------- | ----------------------------------------- |
|
||||||
|
| `library_header` | Before the stats grid in the Library view |
|
||||||
|
| `library_sidebar` | After the stats grid in the Library view |
|
||||||
|
| `search_filters` | Above the Search component in Search view |
|
||||||
|
| `detail_panel` | Above the Detail component in Detail view |
|
||||||
|
|
||||||
|
Multiple plugins can register widgets at the same target; all are rendered in
|
||||||
|
registration order.
|
||||||
|
|
||||||
|
### Complete Example
|
||||||
|
|
||||||
|
A plugin page that lists media items and lets the user trigger a re-scan:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugin]
|
||||||
|
name = "rescan-page"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
id = "rescan"
|
||||||
|
title = "Re-scan"
|
||||||
|
route = "/plugins/rescan-page/rescan"
|
||||||
|
icon = "refresh"
|
||||||
|
|
||||||
|
[ui.pages.actions.trigger-scan]
|
||||||
|
method = "POST"
|
||||||
|
path = "/api/v1/scan/trigger"
|
||||||
|
success_message = "Scan started!"
|
||||||
|
navigate_to = "/plugins/rescan-page/rescan"
|
||||||
|
|
||||||
|
[ui.pages.data_sources.media]
|
||||||
|
type = "endpoint"
|
||||||
|
path = "/api/v1/media"
|
||||||
|
poll_interval = 30
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
gap = 16
|
||||||
|
|
||||||
|
[[ui.pages.layout.children]]
|
||||||
|
type = "flex"
|
||||||
|
direction = "row"
|
||||||
|
justify = "space-between"
|
||||||
|
gap = 8
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.children]]
|
||||||
|
type = "heading"
|
||||||
|
level = 2
|
||||||
|
content = "Library"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.children]]
|
||||||
|
type = "button"
|
||||||
|
label = "Trigger Re-scan"
|
||||||
|
variant = "primary"
|
||||||
|
action = "trigger-scan"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children]]
|
||||||
|
type = "data_table"
|
||||||
|
data = "media"
|
||||||
|
sortable = true
|
||||||
|
filterable = true
|
||||||
|
page_size = 25
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.columns]]
|
||||||
|
key = "title"
|
||||||
|
label = "Title"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.columns]]
|
||||||
|
key = "media_type"
|
||||||
|
label = "Type"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.columns]]
|
||||||
|
key = "file_size"
|
||||||
|
label = "Size"
|
||||||
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue