diff --git a/src/content/docs/v5/plugins/development/entries.mdx b/src/content/docs/v5/plugins/development/entries.mdx index 5678f4b1..2a2b0914 100644 --- a/src/content/docs/v5/plugins/development/entries.mdx +++ b/src/content/docs/v5/plugins/development/entries.mdx @@ -6,6 +6,8 @@ sidebar: order: 3 --- +import PluginApiBadge from '../../../../../components/PluginApiBadge.astro'; + :::caution[Beta - APIs may still change] The plugin system is in **beta**. The manifest format and APIs may still change before v5 is stable, so build with that in mind. ::: @@ -21,12 +23,13 @@ apply depends on the entry kind: | `onScroll(axis, steps, startsGesture)` | ✓ | | | | | | wheel or touchpad scroll over the widget (see below) | | `onQuery(text)` | | | ✓ | | | | launcher text changed (behind the prefix) | | `onActivate(id)` | | | ✓ | | | | a launcher result was selected | +| `onEnable()` | | | | | | ✓ | the plugin was explicitly enabled successfully (see below) | | `onOpen(context)` / `onClose()` | | | | | ✓ | | the panel is opened / closed | | `onKey(chord, pressed)` | | | | | ✓ | | a `capture_keys` chord while the panel is focused ([details](/v5/plugins/development/declarative-ui/#handling-keys-directly)) | | `onFrameTick(deltaMs)` | | | | ✓ | | | every frame, after `desktopWidget.setNeedsFrameTick(true)` | | `onIpc(event, payload)` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `noctalia msg plugin …` | | `onConfigChanged()` | | | | | | ✓ | a plugin setting changed (see below) | -| `onExit(signal)` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | the entry runtime is about to be destroyed (see below) | +| `onExit(signal, reason)` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | the entry runtime is about to be destroyed (see below) | The top level of the script runs once at load - set up state and register `noctalia.state.watch` handlers there. @@ -89,19 +92,65 @@ silences the callback outright. ### Cleaning up when an entry exits -Define `onExit(signal)` to release resources owned by an entry before its Luau runtime is destroyed. It runs when +Define `onExit(signal, reason)` to release resources owned by an entry before its Luau runtime is destroyed. It runs when Noctalia exits normally, when a plugin or entry is disabled or removed, and before a runtime is restarted or reloaded. -The callback receives `2` for a graceful `SIGINT` shutdown, `15` for `SIGTERM`, and `0` for every other teardown. You -may omit the parameter when the reason does not matter: +The first argument remains the process signal: `2` for graceful `SIGINT`, `15` for `SIGTERM`, and `0` for every other +teardown. The optional second argument requires and describes why the +runtime is exiting: + +| Reason | Meaning | +|--------|---------| +| `"disable"` | The enabled plugin is being explicitly disabled. | +| `"uninstall"` | The enabled plugin is being explicitly uninstalled. | +| `"reload"` | The entry is being reloaded, restarted, or otherwise replaced without a process shutdown. | +| `"shutdown"` | Noctalia is shutting down after `SIGINT` or `SIGTERM`. | + +Existing handlers that accept only `signal` remain compatible; Luau ignores the additional argument: ```lua -function onExit(_signal) - noctalia.runAsync("pkill -TERM -x my-plugin-helper 2>/dev/null || true") +function onExit(_signal, reason) + if reason == "disable" then + noctalia.runAsync("systemctl --user stop my-plugin.service") + elseif reason == "uninstall" then + noctalia.runAsync("systemctl --user disable --now my-plugin.service") + end end ``` Keep cleanup short: `onExit` has the normal callback time budget. It cannot run after `SIGKILL`, a process crash, or -another abrupt termination that prevents Noctalia from shutting down gracefully. +another abrupt termination that prevents Noctalia from shutting down gracefully. If cleanup must continue after the +entry VM is destroyed, call `noctalia.runAsync(command)` without a result callback; the detached command outlives the +runtime. An uninstall command must use an installed helper or otherwise avoid plugin-directory files, which may be +removed as soon as the hook returns. A detached command cannot report its eventual result back to the destroyed +runtime, so make cleanup idempotent and expose a way for the user to retry it. + +### Reacting to plugin lifecycle changes in a service + +Every entry can use the extended `onExit(signal, reason)`, while service entries can additionally define `onEnable()`. +Both capabilities require . `onEnable()` runs after an explicit enable +succeeds and after the service runtime has loaded; it also runs when the user re-enables a disabled plugin: + +```lua +function onEnable() + noctalia.runAsync("systemctl --user start my-plugin.service") +end + +function onExit(_signal, reason) + if reason == "disable" then + noctalia.runAsync("systemctl --user stop my-plugin.service") + elseif reason == "uninstall" then + noctalia.runAsync("systemctl --user disable --now my-plugin.service") + end +end +``` + +`onEnable()` describes an explicit plugin-manager action, not every service start. It does not run during ordinary +Noctalia startup, a source update, a script reload, or a settings-driven service restart. Use top-level initialization +for normal service startup. + +Lifecycle hooks can only run while an entry runtime exists. Removing a plugin that was already disabled cannot invoke +`onExit(0, "uninstall")` because none of its entries are loaded. Cleanup that must also cover that sequence should +therefore be idempotent and available through the plugin's normal controls. ### Reacting to settings changes in a service diff --git a/src/data/plugin-api.ts b/src/data/plugin-api.ts index 5d356519..e2972ae2 100644 --- a/src/data/plugin-api.ts +++ b/src/data/plugin-api.ts @@ -111,6 +111,13 @@ export const PLUGIN_API_LEVELS: PluginApiLevel[] = [ feature: 'extended-system-stats', introduced: 'Per-interface network rates, sample timestamps, and disk mount/stat APIs.', }, + { + level: 17, + noctaliaVersion: null, + feature: 'service-lifecycle', + introduced: + 'Service `onEnable()` and the `shutdown`, `disable`, `uninstall`, and `reload` reasons passed to every entry\'s `onExit(signal, reason)`.', + }, ]; export const CURRENT_PLUGIN_API = Math.max(...PLUGIN_API_LEVELS.map((entry) => entry.level));