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
+326
View File
@@ -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.