initial commit
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
# API reference (`LiveWall.Sdk` 1.0)
|
||||
|
||||
Everything below lives in the namespace `LiveWall.Sdk`, in the single assembly `LiveWall.Sdk.dll`
|
||||
(target `net8.0-windows`, WPF). Nullable reference types are enabled.
|
||||
|
||||
---
|
||||
|
||||
## `PluginApi` (static)
|
||||
|
||||
| Member | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `Version` | `"1.0"` | Contract version. Must match the `apiVersion` in `plugin.json`. |
|
||||
| `HostVersion` | e.g. `"1.8.0"` | LiveWall version this SDK shipped with. Informational. |
|
||||
|
||||
## `PluginPermission` (`[Flags]`)
|
||||
|
||||
| Flag | Dialog text |
|
||||
|---|---|
|
||||
| `None` | — |
|
||||
| `ReadWallpaper` | Read your current wallpaper, playback state and settings |
|
||||
| `ControlPlayback` | Start, stop, skip and replace your wallpaper |
|
||||
| `DesktopOverlay` | Draw content on top of your desktop wallpaper |
|
||||
| `SettingsUi` | Add pages to the LiveWall settings window |
|
||||
| `Commands` | Add commands to LiveWall |
|
||||
| `TrayMenu` | Add entries to the LiveWall tray menu |
|
||||
| `Hotkeys` | Register system-wide keyboard shortcuts |
|
||||
| `FileSystem` | Read and write files on your computer |
|
||||
| `Network` | Connect to the internet |
|
||||
| `Renderer` | Take over wallpaper playback with its own renderer |
|
||||
| `Filters` | Rewrite or block wallpaper changes |
|
||||
| `All` | Union of all of the above |
|
||||
|
||||
`PluginPermissionInfo.Describe(PluginPermission)` returns the text for one flag;
|
||||
`PluginPermissionInfo.Expand(PluginPermission)` returns `(Permission, Description)` pairs — this is
|
||||
what the warning dialog iterates.
|
||||
|
||||
> Permissions are **declarations**, not a sandbox. The host enforces them where it can (it will refuse
|
||||
> to register an overlay without `DesktopOverlay`, or to change the wallpaper without
|
||||
> `ControlPlayback`), but nothing stops a determined plugin from calling Win32 directly. They exist so
|
||||
> the user knows what they are agreeing to — see [security.md](security.md).
|
||||
|
||||
---
|
||||
|
||||
## `ILiveWallPlugin`
|
||||
|
||||
```csharp
|
||||
public interface ILiveWallPlugin
|
||||
{
|
||||
void Initialize(IPluginContext context);
|
||||
void Shutdown();
|
||||
}
|
||||
```
|
||||
|
||||
* One public parameterless class per assembly, named by `"type"` in `plugin.json`.
|
||||
* `Initialize` runs on the WPF UI thread. Register extensions and subscribe to events here; do not
|
||||
block (no long `Thread.Sleep`, no `.Result`), and start expensive work on a background task.
|
||||
* Any exception escaping `Initialize` marks the plugin **Failed**; it is never retried automatically
|
||||
and the message is shown in the Plugins tab.
|
||||
* `Shutdown` may run on any thread during unload/exit. It must not block for long and must not throw.
|
||||
|
||||
## `IPluginContext`
|
||||
|
||||
| Member | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Info` | `PluginInfo` | Identity, paths, hash, verification flag |
|
||||
| `Log` | `IPluginLog` | Prefixed with your plugin id, written to LiveWall's log |
|
||||
| `Storage` | `IPluginStorage` | Per-plugin JSON key/value store |
|
||||
| `Settings` | `ISettingsSnapshot` | Read-only settings snapshot (a fresh one each get) |
|
||||
| `Host` | `IHostActions` | Actions in the host |
|
||||
| `Events` | `IPluginEvents` | Subscribe / publish |
|
||||
| `Extensions` | `IPluginRegistrar` | Register overlays, pages, commands, filters, renderers, hotkeys |
|
||||
| `ShutdownToken` | `CancellationToken` | Cancelled when the plugin is being unloaded |
|
||||
|
||||
## `PluginInfo`
|
||||
|
||||
| Member | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Id`, `Name`, `Version`, `Author`, `Description`, `Homepage` | `string` | From `plugin.json` |
|
||||
| `ApiVersion` | `string` | Version the plugin declared (normalised to `"1.0"`) |
|
||||
| `Permissions` | `PluginPermission` | Parsed from the manifest |
|
||||
| `Folder` | `string` | Absolute plugin folder |
|
||||
| `AssemblyPath` | `string` | Absolute path of the entry assembly |
|
||||
| `Sha256` | `string` | Lower-case hex SHA-256 of the assembly |
|
||||
| `Verified` | `bool` | `true` when this exact hash was approved on this machine |
|
||||
|
||||
## `IPluginLog`
|
||||
|
||||
```csharp
|
||||
void Debug(string message);
|
||||
void Info(string message);
|
||||
void Warn(string message);
|
||||
void Error(string message, Exception? exception = null);
|
||||
```
|
||||
|
||||
Lines land in `%APPDATA%\LiveWall\logs\livewall-YYYYMMDD.log` as
|
||||
`[com.example.clock] your message`. `Debug` only writes when LiveWall's debug logging is on.
|
||||
|
||||
## `IPluginStorage`
|
||||
|
||||
| Member | Notes |
|
||||
|---|---|
|
||||
| `string DataFolder` | `%APPDATA%\LiveWall\plugin-data\<id>\`, created on demand |
|
||||
| `T? Get<T>(string key, T? fallback = default)` | Deserialised with `System.Text.Json` |
|
||||
| `void Set<T>(string key, T value)` | Buffered; flushed automatically (debounced) and on shutdown |
|
||||
| `bool Remove(string key)` | Removes one key |
|
||||
| `IReadOnlyCollection<string> Keys` | All keys in the store |
|
||||
| `string PathFor(string fileName)` | Absolute path inside `DataFolder`; parent dirs created |
|
||||
| `void Flush()` | Force a write now |
|
||||
|
||||
The store is one JSON object per plugin (`storage.json`). Concurrent `Set` calls from different
|
||||
threads are safe. Keep it small (it is rewritten wholesale).
|
||||
|
||||
## `ISettingsSnapshot`
|
||||
|
||||
| Member | Type | Notes |
|
||||
|---|---|---|
|
||||
| `SnapshotId` | `Guid` | Changes on every snapshot |
|
||||
| `SourceKind` | `string` | `Video`, `AnimatedGif`, `Image`, `YouTube`, `WebPage` |
|
||||
| `SourceLocation` | `string` | File path or URL |
|
||||
| `Muted` | `bool` | |
|
||||
| `Volume` | `double` | 0–100 |
|
||||
| `Speed` | `double` | 0.25–4 |
|
||||
| `Loop` | `bool` | |
|
||||
| `InteractiveMode` | `bool` | `true` when the wallpaper accepts clicks |
|
||||
| `Paused` | `bool` | |
|
||||
| `DimPercent` | `int` | 0–100 |
|
||||
| `DimColor` | `string` | `#RRGGBB` |
|
||||
| `DisplayDevices` | `IReadOnlyList<string>` | Device names, primary first |
|
||||
|
||||
## `WallpaperState`
|
||||
|
||||
`Kind`, `Source`, `Title`, `IsPlaying`, `IsPaused`, `IsInteractive`, `SuppliedByPlugin`,
|
||||
`RendererName`. This is `IHostActions.Current`.
|
||||
|
||||
## `IHostActions`
|
||||
|
||||
| Member | Permission | Behaviour |
|
||||
|---|---|---|
|
||||
| `WallpaperState Current { get; }` | `ReadWallpaper` | `Kind`/`Source` empty when unknown |
|
||||
| `void SetWallpaper(WallpaperRequest)` | `ControlPlayback` | Routes through the normal filter chain then reloads |
|
||||
| `void Play()` | `ControlPlayback` | |
|
||||
| `void Pause()` | `ControlPlayback` | |
|
||||
| `void TogglePause()` | `ControlPlayback` | |
|
||||
| `void NextWallpaper()` | `ControlPlayback` | Next playlist entry |
|
||||
| `void ReloadWallpaper()` | `ControlPlayback` | Re-applies the current source |
|
||||
| `void Reattach()` | — | Re-parents the wallpaper window to WorkerW |
|
||||
| `void OpenSettings()` | — | Shows the settings window |
|
||||
| `void SetStatus(string)` | — | Status line text |
|
||||
| `void ShowNotification(string, string)` | — | Balloon, respects the user's notification setting |
|
||||
| `void SetOverlayVisible(bool)` | `DesktopOverlay` | Shows/hides **this plugin's** overlays |
|
||||
|
||||
Calls the plugin lacks permission for are logged and ignored — they never throw.
|
||||
|
||||
## `WallpaperRequest`
|
||||
|
||||
```csharp
|
||||
public sealed class WallpaperRequest
|
||||
{
|
||||
public string Kind { get; set; } = "Custom"; // Video | AnimatedGif | Image | YouTube | WebPage | Custom
|
||||
public string Source { get; set; } = ""; // path, URL, or anything your renderer understands
|
||||
public string Title { get; set; } = "";
|
||||
public Dictionary<string, string> Metadata { get; } // free-form, survives filters, not persisted
|
||||
public WallpaperRequest Clone();
|
||||
}
|
||||
```
|
||||
|
||||
`Metadata` keys are case-insensitive. Use it to hand parameters to your own renderer.
|
||||
|
||||
---
|
||||
|
||||
## Extension points
|
||||
|
||||
### `IPluginRegistrar`
|
||||
|
||||
```csharp
|
||||
void AddOverlay(IWallpaperOverlay overlay); // DesktopOverlay
|
||||
void RemoveOverlay(IWallpaperOverlay overlay);
|
||||
void AddSettingsPage(IPluginSettingsPage page); // SettingsUi
|
||||
void AddCommand(PluginCommand command); // Commands / TrayMenu
|
||||
void AddFilter(IWallpaperFilter filter); // Filters
|
||||
void AddRenderer(IWallpaperRendererPlugin renderer); // Renderer
|
||||
void AddHotkey(PluginHotkey hotkey); // Hotkeys
|
||||
```
|
||||
|
||||
Registering without the matching permission logs a warning and does nothing.
|
||||
|
||||
### `IWallpaperOverlay`
|
||||
|
||||
```csharp
|
||||
string Name { get; }
|
||||
FrameworkElement CreateView(OverlayContext context);
|
||||
void Update(OverlayState state);
|
||||
void OnVisibilityChanged(bool visible) { } // default: do nothing
|
||||
```
|
||||
|
||||
* `CreateView` is called once per display, on the UI thread, when the overlay is attached.
|
||||
* The element is stretched to the display's DIP size and **is not hit-testable by default** — a
|
||||
click on your overlay falls through to the desktop. If you need clicks, use `InteractiveMode` plus a
|
||||
renderer instead; the overlay layer is deliberately click-through.
|
||||
* Keep it cheap: it repaints whenever anything below it repaints. Avoid `Effect`s on large elements,
|
||||
prefer text and simple shapes, and stop timers in `OnVisibilityChanged(false)`.
|
||||
|
||||
`OverlayContext`: `DisplayIndex`, `DeviceName`, `Width`, `Height` (DIP), `DpiScale`, `Primary`.
|
||||
|
||||
`OverlayState`: `Kind`, `Source`, `Title`, `IsPlaying`, `IsPaused`, `IsInteractive`, `TimestampUtc`.
|
||||
|
||||
### `IPluginSettingsPage`
|
||||
|
||||
```csharp
|
||||
string Title { get; }
|
||||
string Description { get; }
|
||||
FrameworkElement CreateView();
|
||||
```
|
||||
|
||||
Pages appear under **Plugins ▸ Installed plugins ▸ Settings**. Several pages = several tabs in one
|
||||
window. `CreateView` is called when the window opens, so build fresh controls and read your state
|
||||
there. The element tree is LiveWall's; if it throws, the message is shown in the page instead of
|
||||
crashing the app.
|
||||
|
||||
Style your controls like the rest of the app — `(Brush)Application.Current.Resources["TextBrush"]`,
|
||||
`["TextDim"]`, `["BgField"]`, `["AccentBrush"]`, `["DangerBrush"]`, and the `Card`, `Hint`,
|
||||
`SectionTitle`, `AccentButton`, `GhostButton`, `TinyButton` styles. They are always present.
|
||||
|
||||
### `PluginCommand`
|
||||
|
||||
```csharp
|
||||
new PluginCommand("id", "Human title", ctx => { /* ... */ })
|
||||
{
|
||||
Category = "Clock", // optional grouping label
|
||||
CanExecute = () => true // optional; evaluated when the menu opens
|
||||
};
|
||||
```
|
||||
|
||||
`ctx.Plugin` is the plugin's `IPluginContext`, `ctx.Host` is `IHostActions`. Commands appear in
|
||||
**Plugins ▸ Plugin commands** with a **Run** button; those with the `TrayMenu` permission also appear
|
||||
in the tray menu under **Plugins**.
|
||||
|
||||
### `PluginHotkey`
|
||||
|
||||
```csharp
|
||||
new PluginHotkey("clock.toggle24", "Ctrl+Alt+F9", () => { /* ... */ })
|
||||
{
|
||||
Description = "Clock: toggle 12/24 hour" // tooltip text in the hotkey list
|
||||
};
|
||||
```
|
||||
|
||||
Gestures accept the same syntax as LiveWall's own hotkeys: modifiers `Ctrl`/`Control`, `Alt`,
|
||||
`Shift`, `Win` plus one key (`F1`–`F24`, letters, digits, `Space`, `Pause`, `PrintScreen`,
|
||||
`Insert`/`Delete`/`Home`/`End`/`PageUp`/`PageDown`, arrows, numpad keys). Registration failure (the
|
||||
combination is taken) is logged and reported — LiveWall's own hotkeys always win.
|
||||
|
||||
### `IWallpaperFilter`
|
||||
|
||||
```csharp
|
||||
int Priority { get; } // higher runs first
|
||||
WallpaperRequest Apply(WallpaperRequest request, IHostActions host);
|
||||
```
|
||||
|
||||
Filters run in the UI thread **before** renderer selection, in descending `Priority` order (ties run in
|
||||
registration order). Return the (possibly new) request; returning the argument unchanged leaves it
|
||||
alone. Throwing removes your filter from that pass and is logged — other filters still run.
|
||||
|
||||
A filter can therefore: rewrite `Source` (resolve `ytsearch:` links, expand `%DATE%`), add
|
||||
`Metadata`, or veto by returning the current state (`host.Current`).
|
||||
|
||||
### `IWallpaperRendererPlugin` / `IWallpaperRenderer`
|
||||
|
||||
```csharp
|
||||
public interface IWallpaperRendererPlugin
|
||||
{
|
||||
string Name { get; }
|
||||
int Priority { get; } // higher wins among plugins
|
||||
bool CanRender(WallpaperRequest request);
|
||||
IWallpaperRenderer Create();
|
||||
}
|
||||
|
||||
public interface IWallpaperRenderer : IDisposable
|
||||
{
|
||||
FrameworkElement View { get; } // the wallpaper visual, keep it opaque
|
||||
void Load(WallpaperRequest request); // throw to report a failure
|
||||
void Play();
|
||||
void Pause();
|
||||
void Unload();
|
||||
void ApplyPlayback(bool muted, double volume, double speed);
|
||||
event EventHandler<string>? Failed;
|
||||
event EventHandler? Ended;
|
||||
}
|
||||
```
|
||||
|
||||
LiveWall asks every renderer, highest `Priority` first, whether it `CanRender`s the request. The first
|
||||
`true` wins and the built-in players (video/GIF/YouTube/web) are skipped — so a plugin can take over
|
||||
sources LiveWall would otherwise handle. Then `Create()` is called once, `Load(request)` once per
|
||||
source, and `View` is placed in the wallpaper window. `Failed`/`Ended` surface in the status line and
|
||||
the log. `Dispose` is called when the renderer is replaced or the plugin unloads.
|
||||
|
||||
Remember: the wallpaper window is behind the desktop icons and **not** interactive unless
|
||||
`InteractiveMode` is on.
|
||||
|
||||
---
|
||||
|
||||
## Events (`Events.cs`)
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `AppStartedEvent` | `HostVersion`, `PluginApiVersion` |
|
||||
| `AppStoppingEvent` | `Reason` |
|
||||
| `WallpaperChangedEvent` | `Kind`, `Source`, `RendererName`, `FromPlugin` |
|
||||
| `PlaybackChangedEvent` | `IsPlaying`, `IsPaused`, `Reason` |
|
||||
| `InteractionChangedEvent` | `Interactive` |
|
||||
| `SettingsChangedEvent` | `Property`, `Snapshot` |
|
||||
| `DisplayChangedEvent` | `Devices`, `Reattached` |
|
||||
| `SessionChangedEvent` | `Locked`, `OnBattery`, `RemoteSession` |
|
||||
| `HostErrorEvent` | `Message`, `Source` |
|
||||
| `PluginMessageEvent` | `FromPluginId`, `Topic`, `Payload` (string) |
|
||||
|
||||
```csharp
|
||||
IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class;
|
||||
void Publish<TEvent>(TEvent payload) where TEvent : class;
|
||||
```
|
||||
|
||||
* Handlers always run on the WPF UI thread. Never block in one — bounce work to `Task.Run` if needed.
|
||||
* Always keep the returned `IDisposable` and dispose it in `Shutdown`.
|
||||
* `Publish` anything you like: it is a type-based bus, so you can define your own event classes for
|
||||
plugin-to-plugin messaging (`PluginMessageEvent` is the conventional envelope).
|
||||
* Publishing a host event type is allowed but only reaches other plugins — the host does not re-apply
|
||||
it.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Events, threading and lifetime
|
||||
|
||||
## The bus
|
||||
|
||||
`IPluginEvents` is a type-based publish/subscribe bus owned by LiveWall.
|
||||
|
||||
```csharp
|
||||
IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class;
|
||||
void Publish<TEvent>(TEvent payload) where TEvent : class;
|
||||
```
|
||||
|
||||
* `Subscribe` returns a token: **dispose it** in `Shutdown` (or you leak your plugin into the load
|
||||
context and it will never unload).
|
||||
* Handlers are matched on the exact runtime type (`Subscribe<WallpaperChangedEvent>` only receives
|
||||
`WallpaperChangedEvent`), not on interfaces or base classes.
|
||||
* Handlers run **on the WPF UI thread**, regardless of which thread published. Never block in one and
|
||||
never touch a plugin whose state you cannot see.
|
||||
* Host publications are delivered synchronously as part of the host's own operation; a slow handler
|
||||
therefore slows the wallpaper change. Do the work in `Task.Run` if it is not instant.
|
||||
* Exceptions thrown by a handler are logged and the handler is unsubscribed from that publication
|
||||
onward — a broken handler cannot repeatedly break the app, and cannot break the other handlers.
|
||||
|
||||
## Catalogue
|
||||
|
||||
| Event | When | Useful fields |
|
||||
|---|---|---|
|
||||
| `AppStartedEvent` | After settings load, wallpaper attach and plugin load | `HostVersion`, `PluginApiVersion` |
|
||||
| `AppStoppingEvent` | On exit, before plugins are unloaded | `Reason` |
|
||||
| `WallpaperChangedEvent` | A new source finished loading (or failed to) | `Kind`, `Source`, `RendererName`, `FromPlugin` |
|
||||
| `PlaybackChangedEvent` | Play/pause/suspend, fullscreen pause, battery pause | `IsPlaying`, `IsPaused`, `Reason` |
|
||||
| `InteractionChangedEvent` | Interactive mode toggled | `Interactive` |
|
||||
| `SettingsChangedEvent` | Any settings write; only for non-plugin-related keys | `Property`, `Snapshot` |
|
||||
| `DisplayChangedEvent` | Monitor topology change or WorkerW re-attach | `Devices`, `Reattached` |
|
||||
| `SessionChangedEvent` | Lock screen, battery, RDP | `Locked`, `OnBattery`, `RemoteSession` |
|
||||
| `HostErrorEvent` | Something in the host failed and the user was told | `Message`, `Source` |
|
||||
| `PluginMessageEvent` | Plugin-to-plugin convention | `FromPluginId`, `Topic`, `Payload` |
|
||||
|
||||
Events are **not** queued: you miss whatever happened before you subscribed. Pull the current state
|
||||
from `context.Host.Current` / `context.Settings` during `Initialize` if you need a starting point.
|
||||
|
||||
## Threading cheat sheet
|
||||
|
||||
| Place | Thread |
|
||||
|---|---|
|
||||
| `Initialize`, `Shutdown` | UI thread (Shutdown may be called while the app tears down) |
|
||||
| Overlay `CreateView` / `Update` / `OnVisibilityChanged` | UI thread |
|
||||
| Settings page `CreateView` | UI thread |
|
||||
| Command `Run`, hotkey action | UI thread |
|
||||
| Filter `Apply` | UI thread, inside the wallpaper change |
|
||||
| Renderer `Create` / `Load` / `Play` / `Pause` / `Unload` / `ApplyPlayback` | UI thread |
|
||||
| Event handlers | UI thread |
|
||||
|
||||
Anything you start yourself is your business: use `Task.Run`, dispatch UI updates with
|
||||
`Application.Current.Dispatcher`, and pass `context.ShutdownToken` to cancellable work.
|
||||
|
||||
## Lifetime in order
|
||||
|
||||
```
|
||||
Rescan / enable
|
||||
├─ manifest parsed, assembly hashed, permissions checked
|
||||
├─ assembly loaded into a collectible AssemblyLoadContext
|
||||
├─ PluginInfo filled in, IPluginContext created (storage opened, sub-bus created)
|
||||
└─ Initialize(context) ← you register everything here
|
||||
Disable / rescan / exit
|
||||
├─ ShutdownToken cancelled
|
||||
├─ Shutdown()
|
||||
├─ your overlays, pages, commands, filters, renderers and hotkeys are dropped
|
||||
├─ renderers disposed, overlays unhooked
|
||||
└─ AssemblyLoadContext.Unload()
|
||||
```
|
||||
|
||||
There is no "re-initialise": a plugin that needs to restart shows as **Disabled** then **Active** after
|
||||
a Rescan, with a brand-new context object. Any state you want to survive goes in `IPluginStorage`.
|
||||
@@ -0,0 +1,372 @@
|
||||
# Recipes
|
||||
|
||||
Copy, adapt, ship. Everything here compiles against `LiveWall.Sdk` 1.0; the complete working example
|
||||
is `samples/ClockOverlayPlugin/`.
|
||||
|
||||
---
|
||||
|
||||
## A minimal plugin that just does something
|
||||
|
||||
```csharp
|
||||
using LiveWall.Sdk;
|
||||
|
||||
public sealed class PingPlugin : ILiveWallPlugin
|
||||
{
|
||||
public void Initialize(IPluginContext context)
|
||||
{
|
||||
context.Log.Info("ping plugin ready");
|
||||
|
||||
context.Extensions.AddCommand(new PluginCommand("ping.now", "Ping: notify me",
|
||||
ctx => ctx.Host.ShowNotification("Ping", "Still here.")));
|
||||
}
|
||||
|
||||
public void Shutdown() { }
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "id": "com.example.ping", "name": "Ping", "version": "1.0.0", "apiVersion": "1.0",
|
||||
"entry": "Ping.dll", "type": "PingPlugin", "minHostVersion": "1.8.0",
|
||||
"permissions": ["Commands"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## An overlay that shows the wallpaper's name
|
||||
|
||||
```csharp
|
||||
public sealed class NameOverlay : IWallpaperOverlay
|
||||
{
|
||||
private TextBlock? _text;
|
||||
|
||||
public string Name => "Now playing";
|
||||
|
||||
public FrameworkElement CreateView(OverlayContext context)
|
||||
{
|
||||
_text = new TextBlock
|
||||
{
|
||||
Text = "",
|
||||
FontSize = 13,
|
||||
Foreground = new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Bottom,
|
||||
Margin = new Thickness(24, 0, 0, 20)
|
||||
};
|
||||
return _text;
|
||||
}
|
||||
|
||||
public void Update(OverlayState state)
|
||||
{
|
||||
if (_text == null) return;
|
||||
_text.Text = string.IsNullOrEmpty(state.Source)
|
||||
? ""
|
||||
: $"{state.Kind} · {System.IO.Path.GetFileName(state.Source)}" +
|
||||
(state.IsPaused ? " (paused)" : "");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Update` is called on every wallpaper/playback change — no polling needed.
|
||||
|
||||
---
|
||||
|
||||
## An animated overlay (visualiser-ish) with a timer that behaves
|
||||
|
||||
```csharp
|
||||
public sealed class PulseOverlay : IWallpaperOverlay
|
||||
{
|
||||
private readonly List<Rectangle> _bars = new();
|
||||
private DispatcherTimer? _timer;
|
||||
private readonly Random _random = new();
|
||||
|
||||
public string Name => "Pulse";
|
||||
|
||||
public FrameworkElement CreateView(OverlayContext context)
|
||||
{
|
||||
var panel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
VerticalAlignment = VerticalAlignment.Bottom,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 0, 32),
|
||||
Height = 120
|
||||
};
|
||||
|
||||
for (int i = 0; i < 32; i++)
|
||||
{
|
||||
var bar = new Rectangle
|
||||
{
|
||||
Width = 6,
|
||||
Height = 10,
|
||||
Margin = new Thickness(2, 0, 2, 0),
|
||||
VerticalAlignment = VerticalAlignment.Bottom,
|
||||
Fill = new SolidColorBrush(Color.FromArgb(140, 0x4C, 0xC2, 0xFF))
|
||||
};
|
||||
_bars.Add(bar);
|
||||
panel.Children.Add(bar);
|
||||
}
|
||||
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(80) };
|
||||
_timer.Tick += (_, _) =>
|
||||
{
|
||||
foreach (var bar in _bars)
|
||||
bar.Height = 8 + _random.Next(105);
|
||||
};
|
||||
_timer.Start();
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
public void Update(OverlayState state) { }
|
||||
|
||||
public void OnVisibilityChanged(bool visible)
|
||||
{
|
||||
if (visible) _timer?.Start();
|
||||
else _timer?.Stop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stopping the timer when hidden is what keeps a wallpapered machine cool while a game is fullscreen.
|
||||
|
||||
---
|
||||
|
||||
## A filter that resolves shorthand and env vars
|
||||
|
||||
```csharp
|
||||
public sealed class ResolveFilter : IWallpaperFilter
|
||||
{
|
||||
public int Priority => 100; // run before other plugins' filters
|
||||
|
||||
public WallpaperRequest Apply(WallpaperRequest request, IHostActions host)
|
||||
{
|
||||
var source = request.Source;
|
||||
|
||||
if (source.StartsWith("yt:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var query = source[3..];
|
||||
request.Source = "https://www.youtube.com/results?search_query=" + Uri.EscapeDataString(query);
|
||||
request.Kind = "YouTube";
|
||||
}
|
||||
else if (source.StartsWith("home:"))
|
||||
{
|
||||
request.Source = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), source[5..]);
|
||||
request.Kind = "Video";
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A filter that pins the wallpaper
|
||||
|
||||
```csharp
|
||||
public sealed class PinFilter : IWallpaperFilter
|
||||
{
|
||||
private readonly IHostActions _host;
|
||||
public bool Pinned { get; set; }
|
||||
|
||||
public PinFilter(IHostActions host) => _host = host;
|
||||
|
||||
public int Priority => 1000;
|
||||
|
||||
public WallpaperRequest Apply(WallpaperRequest request, IHostActions host)
|
||||
{
|
||||
if (!Pinned) return request;
|
||||
|
||||
var current = host.Current;
|
||||
return new WallpaperRequest { Kind = current.Kind, Source = current.Source, Title = "pinned" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Toggle `Pinned` from a command or a settings page; the wallpaper stops changing even for playlists.
|
||||
|
||||
---
|
||||
|
||||
## A custom renderer (clock/simulation style)
|
||||
|
||||
```csharp
|
||||
public sealed class ShaderRendererPlugin : IWallpaperRendererPlugin
|
||||
{
|
||||
public string Name => "Example shader";
|
||||
public int Priority => 0;
|
||||
|
||||
public bool CanRender(WallpaperRequest request)
|
||||
=> request.Kind.Equals("Custom", StringComparison.OrdinalIgnoreCase)
|
||||
&& request.Source.StartsWith("example-scene:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public IWallpaperRenderer Create() => new ShaderRenderer();
|
||||
}
|
||||
|
||||
public sealed class ShaderRenderer : IWallpaperRenderer
|
||||
{
|
||||
private readonly Grid _root = new();
|
||||
private DispatcherTimer? _timer;
|
||||
private double _t;
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
public event EventHandler? Ended;
|
||||
|
||||
public FrameworkElement View => _root;
|
||||
|
||||
public void Load(WallpaperRequest request)
|
||||
{
|
||||
var name = request.Source["example-scene:".Length..];
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("no scene name in " + request.Source);
|
||||
|
||||
_root.Background = new SolidColorBrush(Color.FromRgb(8, 10, 16)); // stay opaque
|
||||
_root.Children.Clear();
|
||||
_root.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "scene: " + name,
|
||||
Foreground = Brushes.White,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Render) { Interval = TimeSpan.FromMilliseconds(33) };
|
||||
_timer.Tick += (_, _) => _t += 0.033;
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public void Play() => _timer?.Start();
|
||||
public void Pause() => _timer?.Stop();
|
||||
public void Unload() { _timer?.Stop(); _timer = null; }
|
||||
public void ApplyPlayback(bool muted, double volume, double speed) { /* use them if you like */ }
|
||||
public void Dispose() => Unload();
|
||||
}
|
||||
```
|
||||
|
||||
Declare `"permissions": ["Renderer", "ControlPlayback"]` and the plugin can be selected by setting
|
||||
`Kind = "Custom"`, `Source = "example-scene:rain"` via `host.SetWallpaper(...)`.
|
||||
|
||||
---
|
||||
|
||||
## A settings page with LiveWall styling
|
||||
|
||||
```csharp
|
||||
public sealed class MyPage : IPluginSettingsPage
|
||||
{
|
||||
private readonly IPluginContext _context;
|
||||
public MyPage(IPluginContext context) => _context = context;
|
||||
|
||||
public string Title => "Example";
|
||||
public string Description => "Where the example settings live.";
|
||||
|
||||
public FrameworkElement CreateView()
|
||||
{
|
||||
var res = Application.Current.Resources;
|
||||
|
||||
var panel = new StackPanel();
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "Scene",
|
||||
Foreground = (Brush)res["TextDim"],
|
||||
Margin = new Thickness(0, 0, 0, 4)
|
||||
});
|
||||
|
||||
var combo = new ComboBox { Width = 200, HorizontalAlignment = HorizontalAlignment.Left };
|
||||
combo.ItemsSource = new[] { "rain", "snow", "stars" };
|
||||
combo.SelectedItem = _context.Storage.Get("scene", "rain");
|
||||
combo.SelectionChanged += (_, _) =>
|
||||
{
|
||||
_context.Storage.Set("scene", combo.SelectedItem as string ?? "rain");
|
||||
_context.Host.SetStatus("Example: scene = " + combo.SelectedItem);
|
||||
};
|
||||
panel.Children.Add(combo);
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Content = "Apply now",
|
||||
Style = (Style)res["AccentButton"],
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Margin = new Thickness(0, 14, 0, 0)
|
||||
};
|
||||
button.Click += (_, _) => _context.Host.SetWallpaper(new WallpaperRequest
|
||||
{
|
||||
Kind = "Custom",
|
||||
Source = "example-scene:" + _context.Storage.Get("scene", "rain")
|
||||
});
|
||||
panel.Children.Add(button);
|
||||
|
||||
return panel;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reacting to events without leaking
|
||||
|
||||
```csharp
|
||||
private readonly List<IDisposable> _subs = new();
|
||||
|
||||
public void Initialize(IPluginContext context)
|
||||
{
|
||||
_subs.Add(context.Events.Subscribe<WallpaperChangedEvent>(e =>
|
||||
context.Log.Info($"new wallpaper: {e.Kind} {System.IO.Path.GetFileName(e.Source)}")));
|
||||
|
||||
_subs.Add(context.Events.Subscribe<SessionChangedEvent>(e =>
|
||||
{
|
||||
if (e.Locked) context.Host.Pause(); // cheap power saving
|
||||
else context.Host.Play();
|
||||
}));
|
||||
|
||||
_subs.Add(context.Events.Subscribe<SettingsChangedEvent>(e =>
|
||||
{
|
||||
var s = e.Snapshot; // read-only view of the new settings
|
||||
context.Log.Debug($"settings changed ({e.Property}): volume {s.Volume}, muted {s.Muted}");
|
||||
}));
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
foreach (var sub in _subs) sub.Dispose();
|
||||
_subs.Clear();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Talking to another plugin
|
||||
|
||||
```csharp
|
||||
// sender
|
||||
context.Events.Publish(new PluginMessageEvent
|
||||
{
|
||||
FromPluginId = context.Info.Id,
|
||||
Topic = "weather.now",
|
||||
Payload = """{"temp":18.5,"sky":"rain"}"""
|
||||
});
|
||||
|
||||
// listener (another plugin)
|
||||
context.Events.Subscribe<PluginMessageEvent>(m =>
|
||||
{
|
||||
if (m.Topic == "weather.now") context.Log.Info($"weather: {m.Payload}");
|
||||
});
|
||||
```
|
||||
|
||||
`PluginMessageEvent` is just a convention — the bus is type based, so any class you both reference
|
||||
works. Ship the shared class in a small library, or keep to `PluginMessageEvent` to avoid one.
|
||||
|
||||
---
|
||||
|
||||
## Prefer to write it in the tray only?
|
||||
|
||||
```csharp
|
||||
context.Extensions.AddCommand(new PluginCommand("quiet.toggle", "Mute wallpaper",
|
||||
ctx => { if (ctx.Host.Current.IsPlaying) ctx.Host.Pause(); else ctx.Host.Play(); })
|
||||
{
|
||||
Category = "Audio",
|
||||
CanExecute = () => true
|
||||
});
|
||||
```
|
||||
|
||||
With `"permissions": ["TrayMenu", "ControlPlayback", "ReadWallpaper"]` that is a complete, useful
|
||||
plugin in twelve lines.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Getting started
|
||||
|
||||
This walkthrough builds a plugin that draws a clock on the desktop, then adds a settings page and a
|
||||
tray command. Ten minutes, no LiveWall source needed.
|
||||
|
||||
## 1. Create the project
|
||||
|
||||
```bash
|
||||
mkdir MyClock && cd MyClock
|
||||
dotnet new classlib -n MyClock -f net8.0-windows
|
||||
```
|
||||
|
||||
Edit `MyClock.csproj`:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF> <!-- overlays and pages are WPF elements -->
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Point this at LiveWall.Sdk.dll or its project. Private=false keeps the contract out of
|
||||
your output: LiveWall supplies it at run time. -->
|
||||
<ProjectReference Include="..\LiveWall.Sdk\LiveWall.Sdk.csproj">
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
If you only have the shipped binaries, reference the DLL instead:
|
||||
|
||||
```xml
|
||||
<Reference Include="LiveWall.Sdk">
|
||||
<HintPath>C:\path\to\LiveWall\LiveWall.Sdk.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
```
|
||||
|
||||
> **Never ship `LiveWall.Sdk.dll` in your plugin folder.** LiveWall loads the contract itself; a second
|
||||
> copy would give you a *different* `ILiveWallPlugin` type and the plugin would be rejected.
|
||||
|
||||
## 2. Write the plugin
|
||||
|
||||
`ClockPlugin.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using LiveWall.Sdk;
|
||||
|
||||
public sealed class ClockPlugin : ILiveWallPlugin
|
||||
{
|
||||
private TextBlock? _clock;
|
||||
|
||||
public void Initialize(IPluginContext context)
|
||||
{
|
||||
context.Log.Info("Clock plugin starting");
|
||||
|
||||
context.Extensions.AddOverlay(new ClockOverlay(this));
|
||||
|
||||
context.Extensions.AddCommand(new PluginCommand("clock.say", "Clock: say hello",
|
||||
cmd => cmd.Host.ShowNotification("Clock", "Hello!"))
|
||||
{
|
||||
Category = "Clock"
|
||||
});
|
||||
|
||||
context.Extensions.AddSettingsPage(new ClockSettingsPage(context));
|
||||
}
|
||||
|
||||
public void Shutdown() => _clock = null;
|
||||
|
||||
private sealed class ClockOverlay : IWallpaperOverlay
|
||||
{
|
||||
private readonly ClockPlugin _plugin;
|
||||
public ClockOverlay(ClockPlugin plugin) => _plugin = plugin;
|
||||
|
||||
public string Name => "Clock";
|
||||
|
||||
public FrameworkElement CreateView(OverlayContext context)
|
||||
{
|
||||
_plugin._clock = new TextBlock
|
||||
{
|
||||
Text = "12:00",
|
||||
FontFamily = new FontFamily("Segoe UI Light"),
|
||||
FontSize = 44,
|
||||
Foreground = Brushes.White,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Margin = new Thickness(0, 24, 32, 0)
|
||||
};
|
||||
return _plugin._clock;
|
||||
}
|
||||
|
||||
public void Update(OverlayState state) { /* wallpaper changed - optional */ }
|
||||
}
|
||||
|
||||
private sealed class ClockSettingsPage : IPluginSettingsPage
|
||||
{
|
||||
private readonly IPluginContext _context;
|
||||
public ClockSettingsPage(IPluginContext context) => _context = context;
|
||||
|
||||
public string Title => "Clock";
|
||||
public string Description => "Colour of the desktop clock.";
|
||||
|
||||
public FrameworkElement CreateView()
|
||||
{
|
||||
var box = new TextBox { Text = _context.Storage.Get("colour", "#FFFFFF"), Width = 140 };
|
||||
box.TextChanged += (_, _) => _context.Storage.Set("colour", box.Text);
|
||||
return box;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`plugin.json` (copy it next to the assembly, and add
|
||||
`<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />` to the csproj):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "com.example.clock",
|
||||
"name": "Desktop Clock",
|
||||
"version": "1.0.0",
|
||||
"author": "Your name",
|
||||
"description": "Draws a clock on top of the wallpaper.",
|
||||
"apiVersion": "1.0",
|
||||
"entry": "MyClock.dll",
|
||||
"type": "ClockPlugin",
|
||||
"minHostVersion": "1.8.0",
|
||||
"permissions": ["DesktopOverlay", "SettingsUi", "Commands"]
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Build and install
|
||||
|
||||
```bash
|
||||
dotnet build -c Release
|
||||
mkdir "%APPDATA%\LiveWall\plugins\com.example.clock"
|
||||
copy bin\Release\net8.0-windows\MyClock.dll "%APPDATA%\LiveWall\plugins\com.example.clock\"
|
||||
copy bin\Release\net8.0-windows\plugin.json "%APPDATA%\LiveWall\plugins\com.example.clock\"
|
||||
```
|
||||
|
||||
Then in LiveWall:
|
||||
|
||||
1. **Plugins** tab → tick **Enable the plugin system** → read the warning → accept.
|
||||
2. Press **Rescan plugins**. *Desktop Clock* appears with an **unverified** badge.
|
||||
3. Press **Enable** → the per-plugin warning appears, listing the hash and the three permissions →
|
||||
accept. The clock appears on your desktop.
|
||||
|
||||
The folder can be opened from the app: **Plugins ▸ Open plugin folder**.
|
||||
|
||||
## 4. Debugging
|
||||
|
||||
* Everything you log goes to `%APPDATA%\LiveWall\logs\livewall-YYYYMMDD.log`, prefixed with your
|
||||
plugin id: `[com.example.clock] Clock plugin starting`.
|
||||
* `Plugins ▸ Plugin log` shows the same summary in the app, and **Advanced ▸ Diagnostics** includes a
|
||||
`— Plugins —` section with each plugin's state, hash and permissions.
|
||||
* Attach a debugger with **Debug ▸ Attach to Process ▸ LiveWall.exe**. Your plugin's exceptions are
|
||||
visible in the Visual Studio exception window; `Initialize` is called on the UI thread.
|
||||
* Edit → **Rescan** reloads your assembly without restarting LiveWall (the collectible load context
|
||||
really does unload — see [troubleshooting.md](troubleshooting.md#unloading)).
|
||||
* After changing code, rebuild, overwrite the DLL, and press **Disable** then **Enable** (or Rescan).
|
||||
|
||||
## 5. Next steps
|
||||
|
||||
* [extension-points.md](extension-points.md) — every extension point, with the airspace and threading
|
||||
caveats you will hit as soon as you do something real.
|
||||
* [api-reference.md](api-reference.md) — the complete surface.
|
||||
* [examples.md](examples.md) — recipes: custom renderer, source resolver, playlist provider.
|
||||
@@ -0,0 +1,140 @@
|
||||
# LiveWall plugin SDK
|
||||
|
||||
LiveWall 1.8.0 and later can be extended by **plugins**: .NET 8 assemblies that LiveWall loads at
|
||||
start-up and hands a context object. A plugin can add wallpaper renderers, draw on the desktop, add
|
||||
settings pages, add commands and tray entries, register hotkeys, rewrite wallpaper requests, and read
|
||||
the same events the app itself uses.
|
||||
|
||||
> **Plugins are third-party code.** They run inside LiveWall's process with your rights. LiveWall does
|
||||
> not review, sign or sandbox them, and unverified plugins may cause harm to your computer. The user
|
||||
> is shown a warning listing the plugin's exact file hash and every permission it declared before it is
|
||||
> allowed to run — see [security.md](security.md).
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
| Document | What it covers |
|
||||
|---|---|
|
||||
| [getting-started.md](getting-started.md) | From an empty folder to a running plugin in ten minutes |
|
||||
| [api-reference.md](api-reference.md) | Every interface, member and enum in `LiveWall.Sdk` |
|
||||
| [extension-points.md](extension-points.md) | Overlays, renderers, filters, pages, commands, hotkeys — with the caveats |
|
||||
| [events.md](events.md) | The event catalogue, threading rules and lifetime |
|
||||
| [security.md](security.md) | Permissions, trust, hashes, the warning dialog, threat model |
|
||||
| [packaging.md](packaging.md) | Folder layout, versioning, checksums, distribution, updates |
|
||||
| [troubleshooting.md](troubleshooting.md) | "My plugin does not load", reading the log, unload rules |
|
||||
| [examples.md](examples.md) | Copy-paste recipes for the common shapes |
|
||||
|
||||
The SDK itself is `LiveWall.Sdk.dll` — a small contract assembly. Plugins reference it and nothing
|
||||
else; `LiveWall.exe` is deliberately *not* a dependency.
|
||||
|
||||
---
|
||||
|
||||
## What a plugin can change
|
||||
|
||||
| Extension point | Interface | Typical use |
|
||||
|---|---|---|
|
||||
| Desktop overlay | `IWallpaperOverlay` | Clock, now-playing badge, visualiser, watermark, fps counter |
|
||||
| Wallpaper renderer | `IWallpaperRendererPlugin` + `IWallpaperRenderer` | Shaders, emulators, animated 3D scenes, a custom video pipeline |
|
||||
| Wallpaper filter | `IWallpaperFilter` | Resolve short links, pick a random source, block changes, add metadata |
|
||||
| Settings page | `IPluginSettingsPage` | Your own configuration UI inside LiveWall |
|
||||
| Command | `PluginCommand` | "Next quote", "Toggle rain", anything the user can trigger |
|
||||
| Tray entry | `PluginCommand` + `TrayMenu` permission | Same commands, reachable with two clicks |
|
||||
| Hotkey | `PluginHotkey` | Global shortcut for a plugin action |
|
||||
| Events | `IPluginEvents` | React to wallpaper/playback/session changes |
|
||||
| Host actions | `IHostActions` | Set the wallpaper, play/pause, notify, open settings |
|
||||
| Storage | `IPluginStorage` | Persist your own JSON state, own folders |
|
||||
|
||||
Everything is registered through `IPluginContext.Extensions` inside `Initialize`. There is no
|
||||
"unregister everything" step: LiveWall drops all of a plugin's registrations when it unloads it.
|
||||
|
||||
---
|
||||
|
||||
## How loading works (short version)
|
||||
|
||||
```
|
||||
%APPDATA%\LiveWall\plugins\
|
||||
└── com.example.clock\ ← one folder per plugin, any name
|
||||
├── plugin.json ← identity + permissions (required)
|
||||
├── ClockOverlayPlugin.dll ← entry assembly
|
||||
└── (any other files you need)
|
||||
```
|
||||
|
||||
1. LiveWall scans the plugin folder at start-up (and whenever the user presses **Rescan**).
|
||||
2. `plugin.json` is validated: API version, host version, optional checksum.
|
||||
3. Plugins the user has **not** enabled stay on disk, unloaded, listed in **Plugins ▸ Installed plugins**.
|
||||
4. Enabling runs the warning dialog. Only after the user accepts is the assembly loaded — into a
|
||||
collectible `AssemblyLoadContext` — and `ILiveWallPlugin.Initialize(context)` called.
|
||||
5. Any exception in `Initialize` marks the plugin **Failed** and shows the message. The app keeps
|
||||
running; a plugin can never take LiveWall down.
|
||||
|
||||
Full details in [packaging.md](packaging.md) and [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
dotnet new classlib -n MyPlugin -f net8.0-windows
|
||||
```
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="path\to\LiveWall.Sdk\LiveWall.Sdk.csproj">
|
||||
<Private>false</Private>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
```csharp
|
||||
using LiveWall.Sdk;
|
||||
|
||||
public sealed class MyPlugin : ILiveWallPlugin
|
||||
{
|
||||
public void Initialize(IPluginContext context)
|
||||
=> context.Host.ShowNotification("MyPlugin", "Hello from the wallpaper!");
|
||||
|
||||
public void Shutdown() { }
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "com.example.myplugin",
|
||||
"name": "My plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "you",
|
||||
"description": "Does one small thing.",
|
||||
"apiVersion": "1.0",
|
||||
"entry": "MyPlugin.dll",
|
||||
"type": "MyPlugin",
|
||||
"minHostVersion": "1.8.0",
|
||||
"permissions": ["Commands"]
|
||||
}
|
||||
```
|
||||
|
||||
Copy the build output next to the manifest, then use **Plugins ▸ Rescan** and **Enable**.
|
||||
|
||||
The repository ships a complete, buildable example in `samples/ClockOverlayPlugin/` that uses every
|
||||
extension point — read it next to [extension-points.md](extension-points.md).
|
||||
|
||||
---
|
||||
|
||||
## Stability promise
|
||||
|
||||
The contracts live in `LiveWall.Sdk` and are versioned separately from LiveWall's own version:
|
||||
|
||||
* `PluginApi.Version` (currently **1.0**) is what `plugin.json` must declare.
|
||||
* Adding members to an existing interface would break compiled plugins, so new capabilities arrive as
|
||||
**new optional interfaces**, and the major version only changes if something must be removed.
|
||||
* `minHostVersion` in the manifest refuses to run on an older LiveWall instead of failing mysteriously.
|
||||
|
||||
See [packaging.md](packaging.md#versioning).
|
||||
@@ -0,0 +1,123 @@
|
||||
# Packaging, versioning and distribution
|
||||
|
||||
## Folder layout
|
||||
|
||||
```
|
||||
%APPDATA%\LiveWall\plugins\
|
||||
└── com.example.clock\ ← folder name is free-form; the id comes from plugin.json
|
||||
├── plugin.json ← required
|
||||
├── ClockOverlayPlugin.dll ← the entry assembly ("entry")
|
||||
├── Newtonsoft.Json.dll ← optional private dependencies
|
||||
└── assets\... ← optional data
|
||||
```
|
||||
|
||||
`%APPDATA%\LiveWall\plugin-data\<id>\` is yours to write to at run time (`IPluginStorage`).
|
||||
|
||||
The plugin folder is opened by **Plugins ▸ Open plugin folder**; a starter manifest is written by
|
||||
**Create plugin template** (into `plugins\com.example.myplugin\`).
|
||||
|
||||
## `plugin.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "com.example.clock",
|
||||
"name": "Desktop Clock",
|
||||
"version": "1.0.0",
|
||||
"author": "You",
|
||||
"description": "A clock on the wallpaper.",
|
||||
"homepage": "https://example.com/clock",
|
||||
"apiVersion": "1.0",
|
||||
"entry": "ClockOverlayPlugin.dll",
|
||||
"type": "ClockOverlayPlugin.ClockPlugin",
|
||||
"minHostVersion": "1.8.0",
|
||||
"permissions": ["DesktopOverlay", "SettingsUi", "Commands"],
|
||||
"checksum": "",
|
||||
"loadPriority": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `id` | yes | Reverse-DNS-ish, unique, stable. Used for data folder, trust store and log prefix. No `\ / : * ? " < > \|`. |
|
||||
| `name` | yes | Shown in the UI. Falls back to the folder name. |
|
||||
| `version` | yes | Informational (`1.2.3`). |
|
||||
| `author` | recommended | Shown in the warning dialog. |
|
||||
| `description` | recommended | One sentence. |
|
||||
| `homepage` | no | Where the user can see the code. |
|
||||
| `apiVersion` | yes | Must be `"1.0"` for this SDK. Major must match; `1`, `1.0`, `1.0.0` are accepted. |
|
||||
| `entry` | yes | File name (or relative path) of the assembly inside the plugin folder. |
|
||||
| `type` | yes | Full name of the public class implementing `ILiveWallPlugin`. |
|
||||
| `minHostVersion` | recommended | Refuses to load on older LiveWall instead of failing at `TypeLoadException`. |
|
||||
| `permissions` | yes (may be empty) | Array of `PluginPermission` names, or a single flag string. Unknown names are listed in the warning dialog as "asks for things this LiveWall does not know". |
|
||||
| `checksum` | optional | SHA-256 (hex, case-insensitive) of the entry assembly. When set, a mismatch rejects the plugin. |
|
||||
| `loadPriority` | optional | Higher loads earlier (default `0`). Use it when plugin B expects plugin A's event to already be published. |
|
||||
|
||||
Manifest mistakes are reported in the row (`Invalid manifest: …`) and in the log — nothing is
|
||||
guessed.
|
||||
|
||||
## What can live in the folder
|
||||
|
||||
Everything in the plugin folder is added as a private assembly-probing path, so your own dependencies
|
||||
are found without touching LiveWall's:
|
||||
|
||||
* Private libraries: copy them next to your DLL (normal `dotnet build` output).
|
||||
* **Do not** ship `LiveWall.Sdk.dll`, `LiveWall.dll`, WPF runtime assemblies, or the .NET runtime —
|
||||
they come from the host.
|
||||
* Native DLLs: `AssemblyLoadContext` probing covers managed assemblies only. Use
|
||||
`NativeLibrary.Load(context.Storage.PathFor("your.dll"))` with a copy in your data folder if you
|
||||
need one.
|
||||
* WPF controls with XAML resources: pack URIs of a referenced library work; XAML inside your own
|
||||
assembly works (`/YourAssembly;component/...`).
|
||||
|
||||
## Versioning
|
||||
|
||||
Two independent numbers:
|
||||
|
||||
* **LiveWall plugin API** — `PluginApi.Version`, currently `1.0`. This is what `plugin.json` declares.
|
||||
It changes only when a contract changes incompatibly. New capabilities are added as *new optional
|
||||
interfaces* (e.g. a future `IWallpaperSourceProvider`), which keeps existing plugins compiling and
|
||||
loading unchanged.
|
||||
* **LiveWall host version** — `1.8.0` today. `minHostVersion` is compared numerically (`1.6.10` is
|
||||
newer than `1.6.9`), so you can safely require a bug fix without pinning a build.
|
||||
|
||||
Compatibility check on load, in order:
|
||||
|
||||
1. `apiVersion` major ≠ host API major → **Incompatible**, with the two versions in the message.
|
||||
2. `minHostVersion` newer than the running host → **Incompatible**.
|
||||
3. `checksum` present and different → **Rejected** (`ChecksumMismatch`).
|
||||
4. Entry/type missing or not implementing `ILiveWallPlugin` → **Failed**.
|
||||
5. Exception in `Initialize` → **Failed**.
|
||||
|
||||
## Distributing a plugin
|
||||
|
||||
There is no plugin store; distribution is a folder. The simplest safe pattern:
|
||||
|
||||
1. Ship a zip with exactly the plugin folder inside (`com.example.clock\...`).
|
||||
2. Publish the SHA-256 of your DLL in the release notes.
|
||||
3. Tell users: unpack into `%APPDATA%\LiveWall\plugins\`, then **Plugins ▸ Rescan ▸ Enable**, and
|
||||
compare the hash the warning dialog shows against your published one.
|
||||
|
||||
Putting the same value in `plugin.json`'s `checksum` makes the app verify it automatically and turns
|
||||
a corrupt download into a clear error instead of a mysterious load failure.
|
||||
|
||||
### Updating a plugin
|
||||
|
||||
Replace the files, press **Rescan**. The old load context is unloaded first. Because the DLL has
|
||||
changed, the plugin becomes unverified and the user must approve the new hash — this is deliberate
|
||||
and cannot be bypassed by the plugin itself.
|
||||
|
||||
### Uninstalling
|
||||
|
||||
**Disable** the plugin, delete its folder, and optionally delete `%APPDATA%\LiveWall\plugin-data\<id>`.
|
||||
|
||||
## Building the sample
|
||||
|
||||
```bash
|
||||
cd samples/ClockOverlayPlugin
|
||||
dotnet build -c Release
|
||||
# bin/Release/net8.0-windows/{ClockOverlayPlugin.dll, plugin.json}
|
||||
```
|
||||
|
||||
The sample's csproj sets `<Private>false</Private>` on the SDK reference and
|
||||
`CopyLocalLockFileAssemblies=false`, so its output contains only the plugin assembly — exactly what
|
||||
you want to copy into the plugins folder.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Security model
|
||||
|
||||
LiveWall is a wallpaper app that can run third-party code. This page states plainly what that means,
|
||||
what the app does about it, and what it cannot do.
|
||||
|
||||
## The blunt version
|
||||
|
||||
A plugin is a .NET assembly loaded **into LiveWall's own process**, with LiveWall's rights. There is
|
||||
no sandbox, no separate account, no permission-checking kernel layer. A plugin can:
|
||||
|
||||
* read and write any file your user account can reach (documents, browser profiles, keys);
|
||||
* open network connections and upload whatever it found;
|
||||
* change or delete your LiveWall settings and wallpapers;
|
||||
* register global hotkeys and log keystrokes of its own (as any process can);
|
||||
* crash LiveWall (only the plugin's own failures are contained — see below).
|
||||
|
||||
The permission list is therefore **informed consent, not a security boundary**. LiveWall enforces it
|
||||
where enforcement makes sense (it will not register an overlay without `DesktopOverlay`), and the
|
||||
warning dialog shows exactly what a plugin asked for, but a hostile plugin that lies in its manifest
|
||||
is only limited by your user account.
|
||||
|
||||
Install plugins the way you install any other program: from a source you trust, ideally with a
|
||||
reviewable repository.
|
||||
|
||||
## What LiveWall actually does
|
||||
|
||||
### The warning before anything runs
|
||||
|
||||
Two gates, both modal, both requiring an explicit acknowledgement:
|
||||
|
||||
1. **The plugin system itself.** Turning on *Enable the plugin system* shows a full-screen-format
|
||||
warning: third-party code, unverified plugins may cause harm to your computer, nothing is
|
||||
sandboxed, only proceed with sources you trust. The checkbox must be ticked before *Enable plugins*
|
||||
becomes clickable. `ConsentGiven` is stored, so this is asked once per installation.
|
||||
|
||||
2. **Every single plugin.** Enabling a plugin shows a card with name, author, version, id, folder,
|
||||
its **SHA-256 hash**, a verified/unverified verdict, and the full permission list expanded into
|
||||
human sentences. Again the acknowledgement must be ticked first.
|
||||
|
||||
The same warning banner stays visible at the top of the **Plugins** tab permanently — the user is
|
||||
never more than one click from the reminder.
|
||||
|
||||
### Off by default
|
||||
|
||||
`Plugins.Enabled` is `false` in a fresh configuration, and `AllowUnverified` is `false` as well. With
|
||||
both off, nothing in `%APPDATA%\LiveWall\plugins` is loaded at all; the folder is only scanned so the
|
||||
list can be shown.
|
||||
|
||||
### Hash-pinned trust
|
||||
|
||||
* Every plugin's entry assembly is hashed (SHA-256) at load time.
|
||||
* When the user accepts a warning, that exact hash is stored in
|
||||
`%APPDATA%\LiveWall\plugin-trust.json` as `{ "<plugin id>": "<sha256>" }`, and mirrored into
|
||||
`settings.json` (`Plugins.Plugins[] { Enabled, ApprovedHash }`).
|
||||
* If the DLL changes — an update, but also the first sign of tampering — the hash no longer matches,
|
||||
the plugin is **unverified** again, and the warning reappears. There is no way to enable a
|
||||
mismatched plugin silently: the *Enable* button path always shows the dialog.
|
||||
* **Forget trust** (in the row, visible for verified plugins) removes the stored hash; the next
|
||||
enable asks again.
|
||||
* A manifest may also carry a `checksum`; when present it must match the assembly or the plugin is
|
||||
rejected outright (`ChecksumMismatch`), which lets a publisher pin the exact build they shipped.
|
||||
|
||||
### Failing safely
|
||||
|
||||
* `Initialize`, every command, page build, filter pass, renderer call, hotkey action and event
|
||||
handler is wrapped: exceptions are logged with the plugin id and the offending feature stops being
|
||||
used. The plugin shows as **Failed** with the message.
|
||||
* A plugin that fails to load is never retried in a loop.
|
||||
* A crashing native plugin can still take the process down (as can any in-process code). LiveWall
|
||||
logs each step of loading so the culprit is identifiable afterwards.
|
||||
* LiveWall never executes a plugin it has not been explicitly told to enable — there is no
|
||||
auto-load from a zip, download, or command line.
|
||||
|
||||
### Isolation of the file system
|
||||
|
||||
* Plugin data goes to `%APPDATA%\LiveWall\plugin-data\<id>\` (`IPluginStorage`). Folder names are
|
||||
sanitised from the plugin id, so `../../Windows` cannot be used to escape.
|
||||
* The plugin folder itself is `%APPDATA%\LiveWall\plugins\<folder>\`. Nothing else in the app's own
|
||||
data folder is exposed through the SDK except `Storage.PathFor`, which is confined to the plugin's
|
||||
data folder.
|
||||
|
||||
### Assembly loading
|
||||
|
||||
Plugins are loaded into a **collectible `AssemblyLoadContext`** that has `LiveWall.Sdk` shared with
|
||||
the host. This means:
|
||||
|
||||
* two plugins can ship different versions of the same helper library without colliding;
|
||||
* unloading actually releases the assembly, so a fixed plugin can be retried after **Rescan**;
|
||||
* a plugin cannot accidentally shadow `LiveWall.Sdk` and hand back the wrong interface type.
|
||||
|
||||
## What LiveWall does *not* do
|
||||
|
||||
* It does not sandbox, virtualise or restrict the plugin's file/network/process access.
|
||||
* It does not scan plugins for malware, and does not transmit hashes anywhere.
|
||||
* It does not "verify" against a public signature: **verified** on this machine means *"this exact
|
||||
file is the one you approved"*, not *"this file is trustworthy"*.
|
||||
* It does not protect against a plugin that persists (auto-start entry, scheduled task) — check
|
||||
`shell:startup`, `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` and Task Scheduler if you
|
||||
distrust something you ran.
|
||||
* It cannot undo what a plugin already did to your files. Back up first if you are experimenting.
|
||||
|
||||
## Advice for users
|
||||
|
||||
1. Turn the plugin system on only when you have a plugin you actually want.
|
||||
2. Read the permission list. `FileSystem` + `Network` together is "it can upload my files" — that had
|
||||
better be a plugin you trust.
|
||||
3. Prefer plugins whose source you can read. A DLL alone is a black box.
|
||||
4. If the hash of a plugin you have been using changes after an update, that is expected — but it is
|
||||
also exactly what tampering looks like, so treat the new warning as real.
|
||||
5. To be sure a plugin is gone: **Disable** it, delete its folder, and delete
|
||||
`%APPDATA%\LiveWall\plugin-data\<id>`.
|
||||
|
||||
## Advice for developers
|
||||
|
||||
* Declare **all** permissions you use, and nothing else. Over-declaring trains users to click through.
|
||||
* Never call `SetWallpaper` in a loop; it reloads media.
|
||||
* Don't write outside `context.Storage.DataFolder`. If you need a user-chosen location, ask for it
|
||||
explicitly and remember it.
|
||||
* Long work belongs on the thread pool; the UI thread stalls the wallpaper and your own overlay.
|
||||
* Log through `context.Log`, not to your own file, so everything is in one place for support.
|
||||
* Treat the returned `IDisposable`s and `ShutdownToken` seriously: unload happens while the user is
|
||||
watching, and a plugin that leaves a timer running keeps the load context alive.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Troubleshooting
|
||||
|
||||
## First stop: the log
|
||||
|
||||
Everything is in `%APPDATA%\LiveWall\logs\livewall-YYYYMMDD.log`. Plugin lines are prefixed with the
|
||||
plugin id:
|
||||
|
||||
```
|
||||
[com.example.clock] Clock plugin starting (plugin API 1.0, host 1.8.0)
|
||||
[plugins] loaded com.example.clock v1.0.0 (sha256 3f8c…, verified)
|
||||
```
|
||||
|
||||
Also useful:
|
||||
|
||||
* **Plugins ▸ Plugin log** — in-app summary: every plugin, its state, hash, permissions, overlays,
|
||||
pages, commands, filters, renderers.
|
||||
* **Advanced ▸ Diagnostics** — the same report inside the full diagnostics dump, plus the engine state.
|
||||
* **Advanced ▸ Open log folder**.
|
||||
|
||||
## The plugin does not appear in the list
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Folder listed but named after the folder | `plugin.json` missing or unparsable | Fix the JSON; the row says `Invalid manifest: …` |
|
||||
| Nothing at all | Wrong folder | Must be `%APPDATA%\LiveWall\plugins\<something>\plugin.json`, not `plugins\plugin.json` |
|
||||
| Nothing at all | Plugin system off | **Plugins ▸ Enable the plugin system** (warning required) |
|
||||
| Appears, greyed state `Incompatible` | `apiVersion` major mismatch or `minHostVersion` too new | Update the plugin, or lower `minHostVersion` |
|
||||
| Appears, `Rejected` | `checksum` in the manifest does not match the DLL | Rebuild, or clear `checksum` |
|
||||
| Appears, `Failed` | Assembly/type problem, or `Initialize` threw | Read the state message; fix the type name or the exception |
|
||||
| Appears, `NotApproved` | Loaded and ready, waiting for the user | Press **Enable** and accept the warning |
|
||||
|
||||
## `Enable` does nothing / warning reappears every time
|
||||
|
||||
* **"allow unverified plugins" is off** and the plugin is unverified → turn that option on (in the
|
||||
*Plugin system* card) or use a build whose hash you approved before.
|
||||
* The hash changes on every build (normal). Approving a build approves *that* binary; rebuild and you
|
||||
are asked again. During development, leave **Allow unverified** on and keep an eye on the log rather
|
||||
than re-approving each time.
|
||||
* A **Forget trust** click clears the stored hash — that is what it does.
|
||||
|
||||
## "Could not load file or assembly … LiveWall.Sdk"
|
||||
|
||||
The plugin was built with an *incompatible* SDK, or it shipped its own `LiveWall.Sdk.dll`. Delete the
|
||||
copy from the plugin folder and reference the SDK with `<Private>false</Private>`.
|
||||
|
||||
## "Cannot cast … to ILiveWallPlugin"
|
||||
|
||||
Same cause: a second copy of the contract assembly. The plugin's `ILiveWallPlugin` came from a
|
||||
different assembly identity than the host's.
|
||||
|
||||
## Overlay does not show up
|
||||
|
||||
1. Is the plugin **Active** and does the log mention `overlay registered`?
|
||||
2. Is the desktop icon window alive? LiveWall creates the overlay host window even with desktop icons
|
||||
disabled, but a third-party shell replacement (StartAllBack, ExplorerPatcher) can break WorkerW —
|
||||
press **Re-attach** on the Display tab.
|
||||
3. Did you return a `FrameworkElement` with a size? A `StackPanel` with no children is invisible.
|
||||
4. Is it *behind* the icons by design? The overlay sits above the wallpaper and below the icons; text
|
||||
in the top-right corner is a safe place to test.
|
||||
5. Did you call `OnVisibilityChanged` correctly? LiveWall calls it with `false` when overlays are
|
||||
hidden (tray **Hide overlay**), so a timer you stopped there must be restarted on `true`.
|
||||
|
||||
## Clicks on my overlay do nothing
|
||||
|
||||
Expected. The overlay layer is `IsHitTestVisible = false`: clicks must reach the desktop icons. If you
|
||||
need interaction, that is a *renderer* feature (renderers live in the wallpaper window and can be made
|
||||
interactive with `InteractiveMode`), not an overlay feature.
|
||||
|
||||
## Settings page is blank
|
||||
|
||||
`CreateView` threw. The exception is shown in the page *and* logged. The usual culprit is referencing
|
||||
`LiveWall.Themes` resources by key that does not exist — the safe keys are `BgWindow`, `BgCard`,
|
||||
`BgField`, `BgHover`, `BorderBrushSoft`, `TextBrush`, `TextDim`, `AccentBrush`, `AccentSoft`,
|
||||
`GreenBrush`, `DangerBrush`, and the styles `Card`, `Hint`, `SectionTitle`, `PageTitle`,
|
||||
`AccentButton`, `GhostButton`, `TinyButton`, `Label`, `Mono`.
|
||||
|
||||
## Hotkey does nothing
|
||||
|
||||
* Another app (or LiveWall itself) already owns the combination — the log says so.
|
||||
* Gesture syntax: modifiers first (`Ctrl+Alt+K`), one key. `Ctrl+Shift+3` is not valid, `Ctrl+Shift+D3`
|
||||
is.
|
||||
* The `Hotkeys` permission must be declared.
|
||||
|
||||
## Filter breaks wallpaper changes
|
||||
|
||||
Filters run **before** the engine applies a request, including requests from playlists and other
|
||||
plugins. A filter that always returns a fixed request effectively pins the wallpaper (that is the point
|
||||
of the sample's *Pin wallpaper* option). Exceptions inside `Apply` disable your filter for that pass
|
||||
and are logged; they do not disable the plugin.
|
||||
|
||||
## Renderer never gets used
|
||||
|
||||
* `CanRender` must return `true` for the request the engine actually sees — after filters. Log what
|
||||
you receive.
|
||||
* Another plugin with a higher `Priority` claimed it first. Priorities are per plugin; the first `true`
|
||||
wins.
|
||||
* The `Renderer` permission must be declared, otherwise `AddRenderer` is refused.
|
||||
* After a source change LiveWall calls `Create` once and `Load` per source. If `Load` throws, the
|
||||
message appears in the status line and the plugin is not retried for that source.
|
||||
|
||||
## Unloading
|
||||
|
||||
LiveWall unloads a plugin when it is disabled, rescanned, or when the app exits. The collectible load
|
||||
context only actually releases the assembly if nothing keeps a reference alive:
|
||||
|
||||
* unsubscribe every `IDisposable` you got from `Events.Subscribe` in `Shutdown`;
|
||||
* stop and unhook `DispatcherTimer`s, `System.Timers.Timer`s, `FileSystemWatcher`s;
|
||||
* do not keep static references **in the host's types** to your objects (a static in a host-owned class
|
||||
is fine only if it lives in your assembly);
|
||||
* do not leave `Task`s running: observe `context.ShutdownToken`.
|
||||
|
||||
If a plugin stays "loaded" after Disable, the log says `unload deferred` — restart LiveWall to finish
|
||||
the job. This is a leak in the plugin, not in LiveWall.
|
||||
|
||||
## Reporting a bug
|
||||
|
||||
Include: LiveWall version, plugin id + version + hash (from the warning dialog or **Plugin log**), the
|
||||
relevant log excerpt, and the exact steps. If the app crashed, the last lines of the log before the
|
||||
crash are usually enough to identify the plugin.
|
||||
Reference in New Issue
Block a user