initial commit

This commit is contained in:
2026-11-04 14:29:22 -06:00
parent 73028cc5ba
commit 3f37abf34a
170 changed files with 22100 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
# 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`.