Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Special-character rendering for the display lives in `graphics/special_char.rs`

Plugins are Lua scripts (mlua, `lua54` vendored) returning a table `M`. The engine calls lifecycle/event hooks by name: `on_load`, `on_unload`, `on_serial_connect`/`on_serial_disconnect`/`on_serial_send`/`on_serial_recv` (and `on_rtt_*` equivalents), plus any `M.<name>` the user calls via `!plugin <name>`. Plugins reach back into the app through the `bridge`/`method_call` gates. See `plugins/README.md` for the plugin developer guide.

- **Installed plugins** (`plugin/installed.rs`, issue #36): `!plugin install <file>` loads a plugin *and* records its name in a TOML manifest `<config_dir>/scope/plugins/installed.toml` (next to the staged `.lua` files); `!plugin list` prints the set. The engine owns the manifest: `PluginEngineCommand::{InstallPlugin,ListInstalledPlugins}` handle the commands, and `load_installed_plugins` (called once at the top of `task_async`, before the command loop) auto-loads each installed plugin from its staged copy, logging per plugin like `!plugin load`. Install persists only after a successful load, dedupes, and treats a missing/malformed manifest or a broken entry as logged-and-skipped (never fatal — it's program-managed state, not the user's `config.toml`). Uninstall is issue #37.

## Logging

`infra/logger.rs` provides a channel-based logger; each task gets a clone tagged with its source name. Use the `error!`, `warning!`, `success!`, `info!` macros — messages fan in to the Graphics task for display.
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,11 @@ Anything typed on the command bar that starts with `!` is a command. A line with
| `!mute <pattern>` | Hide received messages matching the regex `<pattern>` (the inverse of `!filter`, like `grep -v`). Call with no argument to mute every received message (a warning is logged). Display-only — the session record keeps every message. |
| `!send_file <path>` | Stream a file to the target over the active interface. |
| `!log <module> <level>` | Set the log level. `<module>` is `system` (`sys`) or a plugin name; `<level>` is one of `debug`, `info`, `success`, `warning`, `error`. |
| `!plugin load <file>` | Load a Lua plugin from a file. |
| `!plugin load <file>` | Load a Lua plugin from a file (for this session only). |
| `!plugin reload <file>` | Reload a plugin from a file. |
| `!plugin unload <name>` | Unload a plugin by name. |
| `!plugin install <file>` | Load a plugin **and** install it, so every subsequent `Scope` session auto-loads it. |
| `!plugin list` | List the installed (auto-loaded) plugins. |
| `!plugin unload <name>` | Unload a plugin by name (for this session; does not uninstall). |
| `!<plugin> <command> [args...]` | Call a command exported by a loaded plugin (see [Plugins](#plugins)). |

## Keyboard & Mouse Shortcuts
Expand Down Expand Up @@ -352,7 +354,7 @@ end
return M
```

Load a plugin with `!plugin load <file>` (and `!plugin reload <file>` / `!plugin unload <name>` to reload or remove it). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md).
Load a plugin with `!plugin load <file>` (and `!plugin reload <file>` / `!plugin unload <name>` to reload or unload it). To keep a plugin across sessions, use `!plugin install <file>`: it loads the plugin now and records it so every subsequent `Scope` session auto-loads it at start-up (see `!plugin list` to review the installed set). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md).

![Plugin usage](videos/011_plugin/video.gif)

Expand Down
2 changes: 2 additions & 0 deletions plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ return M

To execute this plugin you need to load it into the `Scope`. With the `Scope` open, you could type `!plugin load hello.lua`. If you remember the analogy of the chip and the handheld tool, then you need to insert the chip into handheld to it works. Likewise, we need to "insert" (or load) our plugin into our "handheld" (or the `Scope` program). With the plugin loaded, all messages will be replied. The replied message will have the following suffix: `Hello,`.

`!plugin load` lasts only for the current session. If a plugin is part of your everyday workflow, **install** it instead with `!plugin install hello.lua`: this loads it now and records it so every subsequent `Scope` session auto-loads it at start-up (the name is stored in `installed.toml`, next to the staged plugins in `<config_dir>/scope/plugins/`). Use `!plugin list` to see what's installed.

## Hello, World

Let's break down each line of the sample above. At the first line we're importing the `serial` functions from the scope standard library. We need this import to interact with the current connected serial port.
Expand Down
37 changes: 26 additions & 11 deletions src/inputs/inputs_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1321,35 +1321,50 @@ impl InputsTask {
}
}
"plugin" => {
if command_line_split.len() < 3 {
let Some(subcommand) = command_line_split.get(1).map(String::as_str) else {
error!(
private.logger,
"Insufficient arguments for \"!plugin\" command"
"Please, use \"load\", \"reload\", \"install\", \"list\" or \"unload\" subcommands"
);
return;
};

// `list` is the only subcommand that takes no argument.
if subcommand == "list" {
let _ = private
.plugin_engine_cmd_sender
.send(PluginEngineCommand::ListInstalledPlugins);
return;
}

let command = command_line_split[1].as_str();
let Some(arg) = command_line_split.get(2).cloned() else {
error!(
private.logger,
"Insufficient arguments for \"!plugin {}\" command", subcommand
);
return;
};

match command {
match subcommand {
"load" | "reload" => {
let filepath = command_line_split[2].clone();

let _ = private
.plugin_engine_cmd_sender
.send(PluginEngineCommand::LoadPlugin { filepath });
.send(PluginEngineCommand::LoadPlugin { filepath: arg });
}
"install" => {
let _ = private
.plugin_engine_cmd_sender
.send(PluginEngineCommand::InstallPlugin { filepath: arg });
}
"unload" => {
let plugin_name = command_line_split[2].clone();

let _ = private
.plugin_engine_cmd_sender
.send(PluginEngineCommand::UnloadPlugin { plugin_name });
.send(PluginEngineCommand::UnloadPlugin { plugin_name: arg });
}
_ => {
error!(
private.logger,
"Invalid command. Please, choose one of these options: load, reload, unload"
"Invalid command. Please, choose one of these options: load, reload, install, list, unload"
);
}
}
Expand Down
129 changes: 128 additions & 1 deletion src/plugin/engine.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use super::{
Plugin, PluginUnloadMode,
bridge::{PluginEngineGate, PluginMethodCallGate},
installed::Installed,
messages::{self, PluginExternalRequest, PluginMethodMessage, PluginResponse},
};
use crate::{
error,
error, info,
infra::{
logger::{LogLevel, Logger},
messages::TimedBytes,
Expand Down Expand Up @@ -89,6 +90,13 @@ pub enum PluginEngineCommand {
LoadPlugin {
filepath: String,
},
/// Load the plugin now (like `LoadPlugin`) and record it in the persistent
/// manifest so every subsequent session auto-loads it (issue #36).
InstallPlugin {
filepath: String,
},
/// Log the names of the currently installed (auto-loaded) plugins.
ListInstalledPlugins,
UnloadPlugin {
plugin_name: String,
},
Expand Down Expand Up @@ -171,6 +179,18 @@ impl PluginEngine {
let mut rtt_read_reqs = vec![];
let err_regex = Regex::new(r#".*: \[string ".*"]:"#).unwrap();

// Auto-load every installed plugin (issue #36) before entering the
// command loop, so a user's persistent set is ready without manual
// `!plugin load`s. Each load logs per plugin; a missing/broken installed
// plugin is reported and skipped rather than aborting start-up.
Self::load_installed_plugins(
&mut engine_gate,
&mut plugin_list,
&private.logger,
&err_regex,
)
.await;

'plugin_engine_loop: loop {
if let Ok(cmd) = cmd_receiver.try_recv() {
match cmd {
Expand Down Expand Up @@ -227,6 +247,75 @@ impl PluginEngine {
Err(err) => error!(private.logger, "{}", err_regex.replace(&err, "")),
}
}
PluginEngineCommand::InstallPlugin { filepath } => {
let Some(plugin_name) = Self::get_plugin_name(&filepath) else {
continue 'plugin_engine_loop;
};

let dir = plugins_dir();

// Read the manifest up front so a corrupt file surfaces
// here instead of silently dropping the install.
let mut installed = match Installed::load(&dir) {
Ok(installed) => installed,
Err(err) => {
error!(private.logger, "{}", err);
continue 'plugin_engine_loop;
}
};

// Make sure the plugin is loaded this session. If it
// isn't yet, load it now and bail on failure so a broken
// plugin is never persisted into the auto-load set.
if !plugin_list.contains_key(&plugin_name) {
let Ok(source) = PathBuf::from_str(&filepath);

if let Err(err) = Self::load_plugin(
engine_gate.new_method_call_gate(),
Arc::new(plugin_name.clone()),
source,
&mut plugin_list,
private.logger.clone(),
)
.await
{
error!(private.logger, "{}", err_regex.replace(&err, ""));
continue 'plugin_engine_loop;
}
}

// Persist. `add` dedupes, so re-installing is idempotent.
let newly_added = installed.add(&plugin_name);
if let Err(err) = installed.save(&dir) {
error!(private.logger, "{}", err);
continue 'plugin_engine_loop;
}

if newly_added {
success!(private.logger, "Plugin \"{}\" installed", plugin_name);
} else {
success!(
private.logger,
"Plugin \"{}\" is already installed",
plugin_name
);
}
}
PluginEngineCommand::ListInstalledPlugins => {
match Installed::load(&plugins_dir()) {
Ok(installed) if installed.names().is_empty() => {
info!(private.logger, "No plugins installed");
}
Ok(installed) => {
info!(
private.logger,
"Installed plugins: {}",
installed.names().join(", ")
);
}
Err(err) => error!(private.logger, "{}", err),
}
}
PluginEngineCommand::UnloadPlugin { plugin_name } => {
let Some(plugin) = plugin_list.get_mut(&plugin_name) else {
error!(private.logger, "Plugin \"{}\" not loaded", plugin_name);
Expand Down Expand Up @@ -823,6 +912,44 @@ impl PluginEngine {
.map(|filename| filename.to_string())
}

/// Load every plugin recorded in the install manifest, staging each from its
/// copy in the plugins directory. A malformed manifest, a missing file, or a
/// broken plugin is logged and skipped so a bad entry can't stop scope from
/// starting. Each successful load logs per plugin, mirroring `!plugin load`.
async fn load_installed_plugins(
engine_gate: &mut PluginEngineGate,
plugin_list: &mut HashMap<Arc<String>, Plugin>,
logger: &Logger,
err_regex: &Regex,
) {
let dir = plugins_dir();
let installed = match Installed::load(&dir) {
Ok(installed) => installed,
Err(err) => {
error!(logger, "{}", err);
return;
}
};

for name in installed.names() {
// Load from the staged copy in the plugins directory; `load_plugin`
// re-stages (a no-op when source == dest) and loads it.
let source = dir.join(format!("{}.lua", name));
match Self::load_plugin(
engine_gate.new_method_call_gate(),
Arc::new(name.clone()),
source,
plugin_list,
logger.clone(),
)
.await
{
Ok(_) => success!(logger, "Plugin \"{}\" loaded", name),
Err(err) => error!(logger, "{}", err_regex.replace(&err, "")),
}
}
}

async fn load_plugin(
gate: PluginMethodCallGate,
plugin_name: Arc<String>,
Expand Down
Loading
Loading