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

9.5 KiB
Raw Permalink Blame History

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; copy-paste recipes are in 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.

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:

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.

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 for the catalogue. Two habits:

// 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.