Skip to content

Plugins

A plugin is an npm package carrying both halves: TypeScript for the page and Zig for the core. lepton build, run and dev find plugins among the project’s dependencies and compile them into the core. Their commands still have to be listed under permissions.

On Windows 0.1.9-beta.1 os, fs and dialog were measured in a WebView2 app; clipboard is built for Windows but not measured there yet. dialog opens Windows’ own file dialogs, and fs keeps its scope there by refusing every link out of it, stricter than on Linux. The base folders ($APPDATA, $APPCACHE, …) are Windows’ Known Folders.

Terminal window
npm create lepton-app@latest my-app -- --plugins fs,dialog
# or later
npm install @lepton-desk/plugin-fs

The function names follow Tauri’s plugins, so moving code over is mostly an import change.

readTextFile, writeTextFile, readFile, writeFile, readDir, mkdir, remove, rename 0.1.5, stat, exists, BaseDirectory. Each command is fs. plus its name in snake case: fs.read_file (both read functions), fs.write_file, fs.read_dir, fs.mkdir, fs.remove, fs.rename, fs.stat, fs.exists.

{
"permissions": ["fs.read_file", "fs.write_file", "fs.mkdir"],
"plugins": { "fs": { "scope": ["$APPDATA", "$DOCUMENT/notes"] } }
}

A scope entry is a base directory — $APPDATA $APPCONFIG $APPCACHE $HOME $DOCUMENT $DOWNLOAD $DESKTOP $PICTURE $MUSIC $VIDEO — optionally followed by names below it. No wildcards, no ... Without a scope every path is refused. The user directories come from user-dirs.dirs; on a Turkish desktop $DOCUMENT is ~/Belgeler.

On Linux every path is opened with the kernel’s RESOLVE_BENEATH, so a symbolic link inside an allowed directory cannot lead out of it; a path outside the scope rejects with denied, and so does exists — answering false would tell the page what is where it may not look.

  • writeFile(path, data, { append: true }) adds to the end; mkdir(path, { recursive: true }) makes missing parents.
  • remove(path, { recursive: true }) removes a directory with everything in it; links inside are removed, not followed. 0.1.5
  • rename(from, to) stays inside one scope directory and replaces an existing to in one step. 0.1.5
  • One call carries at most 64 KiB. readFile reads a larger file in pieces; writeFile writes it in pieces under <name>.lepton-save and renames it over the old file, so a write cut short leaves the old file whole. 0.1.5

watch(path, handler) calls handler({ change, name }) for each change: for a directory, an entry created, modified (once its writer closes it), removed, renamedFrom or renamedTo; for the watched path itself, gone. The core pushes each change as it reads it, within a quarter of a second; nothing is polled. The path is opened inside the scope before it is watched, so a link cannot point the watch outside it. It resolves with a function that stops watching. Subdirectories are not watched; Linux only for now (Windows rejects with failed). Commands fs.watch and fs.unwatch. 0.1.9-beta.2

import { BaseDirectory, watch } from "@lepton-desk/plugin-fs";
const stop = await watch("notes", ({ change, name }) => console.log(change, name), {
baseDir: BaseDirectory.AppData,
});

With "chosen": true in the fs settings, what the user picks in @lepton-desk/plugin-dialog joins the scope and is remembered across runs: a chosen folder with everything in it, a chosen file by itself. 0.1.5

"plugins": { "fs": { "scope": ["$APPDATA"], "chosen": true } }

open(options) and save(options), drawn by the desktop through xdg-desktop-portal on Linux and by Windows’ own file dialogs on Windows, so the dialog looks like the rest of the desktop. Each resolves with the chosen path(s), or null when the user cancels; the promise stays pending as long as the dialog is open.

import { open, save } from "@lepton-desk/plugin-dialog";
const files = await open({ multiple: true, filters: [{ name: "Text", extensions: ["txt", "md"] }] });
const folder = await open({ directory: true, title: "Pick a folder" });
const target = await save({ defaultName: "notes.md", acceptLabel: "Export" });

Both take title, defaultPath, filters, acceptLabel and signal; open also multiple and directory, save also defaultName. A chosen path is readable through plugin-fs only inside its scope, or with chosen (above).

{ "permissions": ["dialog.open", "dialog.save"] }

writeText(text) and readText(). Wayland only tells a focused window what the selection is, so both wait for focus; pass { signal } to stop waiting.

readText answers only within five seconds of the user pressing a key or button in the window, as a browser reads the clipboard only during a gesture; otherwise it rejects with a NotAllowedError DOMException. The core decides from the input it sees, so a script that got into the page cannot read what the user copied elsewhere (measured: a read at page load was refused, one right after a click answered). A clipboard manager that must read without that sets "read": "always". 0.1.9-beta.2

{
"permissions": ["clipboard.read_text", "clipboard.write_text"],
"plugins": { "clipboard": { "read": "always" } }
}

platform(), arch(), family(), version(), hostname(), locale(), exeExtension().

{ "permissions": ["os.platform", "os.arch", "os.locale"] }

In a native (non-webview) app the dialog and clipboard commands reject with failed.

A plugin is an npm package whose package.json points at its Zig file:

"lepton": { "plugin": "zig/plugin.zig", "settings": "myplugin" }

plugin.zig exports its commands. They take no app state — only the request bytes and the reply buffer (core.commands from 0.1.9-beta.3; a plugin that must also build in older projects uses core.webviewRegistry, the same declarations):

const core = @import("lepton_core");
const Registry = core.commands;
pub const commands = [_]Registry.PluginCommand{
.{ .name = "myplugin.hello", .run = &hello },
};
fn hello(_: []const u8, out: []u8) Registry.HandlerError!usize {
const text = "hello";
if (text.len > out.len) return error.TooLarge;
@memcpy(out[0..text.len], text);
return text.len;
}

The TypeScript half calls them with invoke from @lepton-desk/api/core (a peer dependency). Ship zig/ in the package’s files. The application still lists each command under permissions.

  • settings names the plugin’s section in lepton.config.json (plugins.myplugin); the core reads it at compile time with core.pluginConfig.
  • pub const private = .{ "myplugin.secret" }; keeps a command’s bytes out of lepton record recordings.
  • A plugin that watches something exports pub fn tick(target: anytype) void; the application’s loop calls it on every turn (at least every 250 ms) with an event target (core.events.Target from 0.1.9-beta.3, the bridge itself before), and it emits with target.emit("myplugin.changed", bytes) (at most 256 bytes). Its TypeScript half declares the event in AppEvents (@lepton-desk/api/event). 0.1.9-beta.2