Files
Marcos Paulo cac6b0ed39
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: registry consumers (grammar, theme, pack), seed reader, wasm plugin host with approval, wanted scan, STIX and Lottie store lookups, plugin auto-disable persistence
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 18:35:47 -03:00

129 lines
5.3 KiB
Markdown

# The dice plugin
The first first-party plugin of NotesCraft: a WebAssembly module that rolls
dice. It is also the walkthrough for plugin authors — everything a plugin has
to do to live in the sandbox is in `src/lib.rs`, and this file explains it.
```
plugin.dice.roll {"expr": "2d6+3"}
-> {"total": 10, "rolls": [4, 3], "text": "2d6+3 = 10 [4,3] +3"}
```
## Build and install
```sh
./build.sh # cargo build --release --target wasm32-unknown-unknown
```
`build.sh` leaves `dice.wasm` next to `manifest.json`. To install the plugin,
copy both into the plugin folder of a vault:
```
<vault>/.notescraft/plugins/dice/manifest.json
<vault>/.notescraft/plugins/dice/plugin.wasm
```
From the registry: `dice` is a registry entry (`plugin:dice`, permission `editor`). Install it in
Settings > Registry (or `registry.install`), switch it on for the vault (`registry.enable`) and approve
its permission (`plugins.approve`); no files are copied by hand. `manifest.json` here is the one the
entry ships, and `crates/engine/tests/addons_first_party.rs` signs, seeds, installs and rolls it.
The folder name must be the plugin id of the manifest. `plugins.list` shows
the plugin, `plugins.enable` switches it on, and after that the command
`plugin.dice.roll` is in the command palette, in the CLI and as an MCP tool.
The module is built from its own workspace (`plugins/dice/Cargo.toml` has its
own `[workspace]`), so building it never touches the lock file or the profile
of the app. `dice.wasm` is a build artefact and is never committed.
## The ABI (SPEC 7.2)
The host loads the module in `wasmi` and speaks a small ABI with it. The
module must export:
| Export | Signature | What it does |
|---|---|---|
| `ns_abi_version` | `() -> i32` | must return `1`; any other value is refused |
| `ns_alloc` | `(i32) -> i32` | hands the host `len` bytes of guest memory |
| `ns_free` | `(i32, i32) -> ()` | gives a block back; a bump arena may ignore it |
| `ns_on_load` | `() -> i32` | lifecycle hook; `0` means "fine" |
| `ns_on_unload` | `() -> i32` | lifecycle hook; `0` means "fine" |
| `ns_on_event` | `(i32, i32) -> i32` | a JSON event; `0` means "fine" |
| `ns_run_command` | `(i32, i32, i32, i32) -> i64` | runs one command |
| `memory` | the linear memory | must be exported under that name |
`ns_run_command` takes the command id and the JSON arguments as `(ptr, len)`
pairs in guest memory — the host takes them from `ns_alloc`, so the guest
reads them back from the block it handed out. Its answer is the JSON result
as a `(ptr, len)` pair packed into an `i64` as `(ptr << 32) | len`; a
**negative** value is an error code, and the host turns it into
`plugin: the plugin returned error code -2`. A trap (an unreachable
instruction, an out-of-bounds access) is reported as a trap and is not the
same thing as a guest error: use an error code for a refusal.
## The host calls (SPEC 7.2, 7.3)
A module may import only the calls of the `ns` module, and only the ones its
permissions cover:
| Import | Permission | Answer |
|---|---|---|
| `ns.log` | — | empty |
| `ns.notice` | — | empty |
| `ns.now` | — | JSON `{"unix_ms": ...}` |
| `ns.vault_read`, `ns.vault_list`, `ns.search`, `ns.links`, `ns.backlinks` | `vault.read` | JSON |
| `ns.vault_write` | `vault.write` | JSON |
| `ns.active_note`, `ns.insert_text` | `editor` | JSON |
**The name of the import must be the name of the host call.** The host checks
every import and refuses a module that imports anything else, so a Rust guest
has to rename its functions:
```rust
#[link(wasm_import_module = "ns")]
unsafe extern "C" {
#[link_name = "now"]
fn ns_now() -> i64;
}
```
An answer always arrives in the slot the host owns for the instance, which is
*not* a block the guest handed out: the guest reads it with a pointer read of
its own. That is the one place where the guest needs `unsafe`, and it is
unavoidable — the ABI is raw memory, and a guest that wants the answer of a
host call has to read the memory the host wrote.
## Randomness
The sandbox has no entropy of its own: no clock, no random device, no
network. `ns.now()` is the only source, so the dice plugin seeds its xorshift
with the `unix_ms` of the clock. Two rolls in the same millisecond therefore
roll the same dice. It is enough to keep a plugin from being perfectly
predictable, and it is the whole of what the sandbox offers.
## What the dice plugin understands
`NdM` with an optional constant: `2d6`, `2d6+3`, `2d6-3`, `1d1000`. At most
100 dice, at most 1000 sides. `kh` and `kl` are **not** supported: the plugin
answers with the error code `-2` (the expression is not one it understands)
or `-3` (the expression is understood but out of bounds, for example `0d6`,
`101d6` or `1d1001`).
With the `editor` permission the command also inserts the `text` of its
answer into the focused note.
## Tests
The plugin has no tests of its own: it is a `no_std` module that imports the
host calls, so it cannot be linked into a test binary on its own. It is
tested end to end instead, through the real loader:
```sh
cargo test -p notescraft-engine --test plugin_dice -- --include-ignored
```
The test loads `plugins/dice/dice.wasm` (the build above), installs it in a
throw-away vault and drives it through the session: the ABI version, the
answer of a roll, the text inserted into the focused note, a hundred dice
inside the fuel budget and the three refused expressions.