Files
LiveWall/docs/plugins/extension-points.md
T
2026-11-04 14:29:22 -06:00

217 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Extension points in detail
This page is about *behaviour*: what each extension point is really good for, how the host drives it,
and the caveats that only show up once you run it. Signatures are in
[api-reference.md](api-reference.md); copy-paste recipes are in [examples.md](examples.md).
Registration order does not matter — everything registered inside `Initialize` is collected and then
applied once the plugin finishes initialising.
---
## 1. Overlays — drawing on the desktop
`context.Extensions.AddOverlay(new MyOverlay())` (permission `DesktopOverlay`).
**Where it draws.** LiveWall owns one transparent, layered, always-behind window per display inside
the shell's desktop layer (the same WorkerW the wallpaper lives in). Above the wallpaper and *below*
the icons, it hosts a `Canvas` — `DesktopIconWindow.OverlayLayer` — and each plugin's view is
stretched across it, in registration order (so a later plugin paints over an earlier one).
**Lifecycle**
| Host action | Your callbacks |
|---|---|
| Plugin enabled / rescan | `CreateView(context)` once per display |
| Wallpaper or playback changed | `Update(state)` |
| Overlays hidden (tray *Hide overlay*), plugin disabled, wallpaper paused | `OnVisibilityChanged(false)` |
| Overlays shown again | `OnVisibilityChanged(true)` |
| Plugin disabled | your view is removed and collected |
**Rules that matter**
* `CreateView` must return a *fresh* element each time it is called (it is called once per display,
and again if the overlay host is rebuilt). Do not return a `Window`, and do not add your element to
another parent.
* Position with `HorizontalAlignment`/`VerticalAlignment`/`Margin`, or use a `Canvas` and
`Canvas.SetLeft/Top`. Do not assume a specific size: the root is stretched to the display, in DIPs.
* **No hit testing.** The layer is `IsHitTestVisible = false`, so mouse events never reach you and
never block the desktop. If you need clicks, use a renderer with `InteractiveMode`.
* Transparent pixels cost nothing visually; opaque full-screen rectangles do. Prefer text, `Path`,
`Ellipse`, gradients with alpha.
* Avoid `BitmapEffect`/heavy `Effect` on large elements and avoid animating layout properties; prefer
`RenderTransform` animations.
* Timers: `DispatcherTimer` with `DispatcherPriority.Background`, interval ≥ 1 s for clocks, ~16 ms for
animation, and stop them in `OnVisibilityChanged(false)`. Remember `Update` is not a timer.
* Don't paint over the icon area with anything opaque — the whole point of the layer is that icons
stay usable.
**Multi-monitor.** One `CreateView` per display, each with its own `OverlayContext`; use
`context.Primary` if you only want the main screen (return an empty `Canvas` for the others).
---
## 2. Wallpaper filters — rewriting requests
`context.Extensions.AddFilter(new MyFilter())` (permission `Filters`).
The engine builds a `WallpaperRequest` (`Kind`, `Source`, `Title`, `Metadata`) and asks every filter,
highest `Priority` first, to transform it. What you return is applied — including the built-in
renderers' input, playlist advance, and requests made by *other* plugins via
`host.SetWallpaper`.
Good uses:
* resolve a shorthand (`yt:meatloaf` → a real URL),
* expand environment variables (`%USERPROFILE%\pics\a.mp4`),
* pick a random file from a folder when the source is `random:folder`,
* add metadata for your own renderer (`request.Metadata["scene"] = "rain"`),
* veto: return the current wallpaper (`host.Current`) to refuse the change,
* normalise casing/slashes so two plugins agree on a path.
Bad uses:
* doing I/O on the UI thread (filters run synchronously in the wallpaper path — a 3-second network
call looks like a frozen app),
* returning `null` (treated as "no change", but do it explicitly by returning the argument),
* mutating the request object you were given *and* returning a different one.
Filter exceptions are caught per filter per pass; the pass continues with the previous request.
---
## 3. Renderers — playing what nobody else can
`context.Extensions.AddRenderer(new MyRenderer())` (permission `Renderer`).
`CanRender(request)` is asked for every request, in descending `Priority` (ties run in plugin load
order). The first `true` wins and the built-in players are skipped entirely for that source, so you can
also *replace* video playback with your own pipeline.
```csharp
public bool CanRender(WallpaperRequest r)
=> r.Kind.Equals("Custom", StringComparison.OrdinalIgnoreCase) && r.Source.StartsWith("myprefix:");
```
Then `Create()` → `Load(request)` → (`Play`/`Pause`/`ApplyPlayback` as the user acts) → `Unload()` →
`Dispose()`.
**Rules**
* `View` must be a single element you own; LiveWall puts it in the wallpaper window. Keep the
background opaque (or it shows the desktop colour behind).
* `Load` may throw — that is how you report "this file is not what I expected"; the message goes to
the status line and the log.
* `ApplyPlayback(muted, volume /*0–100*/, speed /*0.25–4*/)` can arrive before or after `Load`, and
repeatedly; make it idempotent.
* Raise `Failed(message)` for problems after load (device lost, decode error) and `Ended` when a
non-looping source finished, so LiveWall can advance the playlist.
* Reported `WallpaperState.RendererName` shows in diagnostics — return a stable `Name`.
* Do not start a second renderer yourself; LiveWall creates one and keeps it for the source's
lifetime.
**Interaction.** With `Displays.Interaction = Interactive` the wallpaper window accepts clicks, so a
renderer can host real UI (a game, a web view, an editor). In the default `ClickThrough` mode the
window is transparent to the mouse and the desktop icons above it stay clickable.
---
## 4. Settings pages
`context.Extensions.AddSettingsPage(new MyPage())` (permission `SettingsUi`).
The page shows up in **Plugins** on the plugin's row via **Settings**. One page → one window; several
pages → one window with tabs. `CreateView` runs when the window opens, so read your storage there and
build fresh controls each time (do not cache the element).
LiveWall's resource dictionary is available through `Application.Current.Resources`, so plugin pages
can look native:
```csharp
var label = new TextBlock
{
Text = "Colour",
Foreground = (Brush)Application.Current.Resources["TextDim"]
};
```
Keep it declarative: you get a `ScrollViewer` around your content, so a long `StackPanel` is fine.
---
## 5. Commands and the tray menu
`context.Extensions.AddCommand(new PluginCommand("id", "Title", ctx => …))`.
* Every command is listed in **Plugins ▸ Plugin commands** with a **Run** button.
* With the `TrayMenu` permission it also appears in the tray under **Plugins ▸**; the submenu is
rebuilt whenever plugins load/unload.
* `CanExecute` is evaluated when the menu opens and in the row rendering — keep it cheap and
side-effect free.
* The `ctx` gives you `ctx.Plugin` (your context) and `ctx.Host` (actions).
* Exceptions inside a command are caught, logged, and reported in the status line.
Use commands for anything the user should be able to trigger: "Next quote", "Toggle rain", "Open my
plugin's data folder", "Reload my config".
---
## 6. Hotkeys
`context.Extensions.AddHotkey(new PluginHotkey("id", "Ctrl+Alt+K", () => …))` (permission `Hotkeys`).
Gestures use LiveWall's own parser: one or more modifiers (`Ctrl`, `Alt`, `Shift`, `Win`), then one
key — `F1`–`F24`, `A`–`Z`, `0`–`9`, `D0`–`D9`, `Space`, `Pause`, `PrintScreen`, `Insert`, `Delete`,
`Home`, `End`, `PageUp`, `PageDown`, arrows, `NumPad0`–`NumPad9`, `NumPad*`. LiveWall's own shortcuts
are registered first; a collision logs a warning and the plugin hotkey is skipped. Hotkeys are
unregistered when the plugin unloads.
The action runs on the UI thread — nothing long inside it.
---
## 7. Storage
`context.Storage` is a JSON object per plugin in `plugin-data\<id>\storage.json`.
```csharp
var colour = context.Storage.Get("colour", "#FFFFFF");
context.Storage.Set("colour", "#00FF88"); // debounced write
File.WriteAllText(context.Storage.PathFor("cache.json"), json); // your own files too
```
Use `PathFor` for anything larger than a few values — everything in the key/value store is rewritten
as one small document. `Flush()` forces a write, and LiveWall flushes automatically on shutdown.
---
## 8. Events and host actions
See [api-reference.md](api-reference.md#events-eventscs) for the catalogue. Two habits:
```csharp
// keep the subscription, dispose it in Shutdown
_subs.Add(context.Events.Subscribe<WallpaperChangedEvent>(OnWallpaperChanged));
public void Shutdown() { foreach (var s in _subs) s.Dispose(); _subs.Clear(); }
```
and never block the UI thread from a handler — they run on it.
`IHostActions` is the write side: `SetWallpaper`, `Play`, `Pause`, `TogglePause`, `NextWallpaper`,
`ReloadWallpaper`, `Reattach`, `OpenSettings`, `SetStatus`, `ShowNotification`, `SetOverlayVisible`.
Each is a no-op (with a log line) if you did not declare the matching permission.
`SetWallpaper` goes back through the filter chain, so two plugins that both set wallpapers cannot
deadlock each other — the last write wins and every filter gets a say.
---
## Putting it together: what a "big" plugin looks like
The sample in `samples/ClockOverlayPlugin/` uses all of the above at once and is the best reference:
* overlay + settings page + command + filter + hotkey + storage + three event subscriptions,
* the settings page writes storage and the overlay picks the change up immediately,
* the filter pins the wallpaper while its option is on,
* the command reports the current wallpaper through `ctx.Host.Current`.