initial commit

This commit is contained in:
2026-11-04 14:29:22 -06:00
parent 73028cc5ba
commit 3f37abf34a
170 changed files with 22100 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
# Antivirus false positives — what is actually happening and what to do about it
LiveWall is flagged by a handful of engines out of seventy. This page explains why, what has already
been done about it, in what order to fix the rest, and what to tell users in the meantime.
Last reviewed: 2026-11-04, for `LiveWall-Setup-1.8.0.exe`.
---
## 1. What the scan actually said
VirusTotal, first submission 2026-11-04, **3 of 71 engines**:
| Engine | Verdict | What that engine is telling you |
|---|---|---|
| SecureAge (APEX) | `Malicious` | Reputation/heuristic verdict; APEX is well known for flagging new, unsigned, low-prevalence files. |
| Trapmine | `Malicious.high.ml.score` | A pure machine-learning score. No signature, no analyst: "this file shape looks like things we have seen". |
| VBA32 | `TScope.Malware-Cryptor.SB` | "SB" = **solid block**. VBA32 scores the NSIS solid archive as a crypter. |
Undetected: **Microsoft Defender, Kaspersky, BitDefender, ESET, Avast/AVG, Malwarebytes, Sophos,
Symantec, Trend Micro, McAfee, GData, Ikarus, ClamAV, DrWeb, CrowdStrike, SentinelOne, Palo Alto,
Fortinet, Webroot**, and 50 more.
Facts from the report that explain the three verdicts:
* **File is not signed.** No Authenticode signature, no publisher.
* **First submission = last submission.** Prevalence zero. Reputation engines have nothing to go on,
so they fall back to heuristics — and the fallback is "guilty until seen a lot".
* **DetectItEasy: `Installer: Nullsoft Scriptable Install System (3.11-1) [zlib,solid]`.** NSIS is the
single most abused installer format in the world, and `/SOLID` hides each file's structure behind
one compressed stream. That is exactly what the VBA32 and Trapmine scores are reacting to.
* Nothing else: no packer (UPX), entropy of `.text` is 6.09 (normal), imports are ordinary, the file
has proper version info (Product, Description, File Version, Copyright).
**This is not a detection of behaviour in the app.** The three engines never ran it — these are static
or reputation verdicts on an unsigned installer stub.
---
## 2. Why a wallpaper app trips heuristics at all
Beyond "unsigned + unknown", the app's own behaviour looks like the shape of a threat to a scanner:
| The app does | Scanners see |
|---|---|
| Finds `WorkerW`, re-parents a hidden window into the shell desktop | "hides a window inside Explorer" |
| Registers global hotkeys | "installs system-wide input handling" |
| Optionally starts with Windows (HKCU `Run`) | "persistence" — **off by default** in LiveWall |
| Loads .NET assemblies from `%APPDATA%\LiveWall\plugins` | "executes code from a user-writable directory" — **off by default**, needs two explicit consents, hash-pinned |
| Ships WebView2 and plays YouTube | "network access" |
None of that is malicious, and none of it is unique to LiveWall — every live-wallpaper app does the
first two. But it means the app will be *judged on reputation*, not on inspection. That is why the
answer is not "make the code cleaner", it is "get a signature and get seen".
Things that were already right (keep them that way): no bundled offers, no telemetry, no admin rights
(`RequestExecutionLevel user`), per-user install, real version metadata on both the installer and the
binaries, uninstaller registered, no obfuscation, no UPX.
---
## 3. Fixes, in order of how much they actually help
### 3.1 Sign everything with Authenticode (the only real fix)
An unsigned download is *the* variable here. It is also what every vendor replies with when you file a
false-positive report ("please sign your binaries").
* **EV code-signing certificate** (~$300–500/year, hardware token/HSM): SmartScreen and Defender
reputation trust it immediately, and heuristics weight signed files far higher.
* **OV code-signing certificate** (~$100–300/year): works, but reputation accumulates over weeks of
downloads. Expect fewer and fewer flags as it builds.
* **Self-signed certificate: do not bother.** An untrusted signature is worth no more than no
signature, and sometimes less.
* Sign **both** the published binaries and the installer, and let NSIS sign the uninstaller it
generates (`installer/sign.ps1` and the `SIGNTOOL` hook in `LiveWall.nsi` do all of this). Always
timestamp (RFC 3161) so signatures survive certificate expiry.
```powershell
# after signing, every file reports a publisher instead of "Unknown publisher"
.\installer\sign.ps1 -Thumbprint <sha1-of-cert> # or -Pfx .\codesign.pfx
```
### 3.2 Report the false positive — every one of the three
Vendors fix these in days when you tell them. Submit the file (or its hash), say what it is, and ask
for a reclassification. Use the "Contact vendor" link on the VirusTotal page for each detection, and:
| Vendor | Where |
|---|---|
| **Microsoft** (even though it currently passes — keeps Defender's cloud verdict clean) | <https://www.microsoft.com/en-us/wdsi/filesubmission> → *Software developer* → "I believe this file is incorrectly detected" |
| **SecureAge** | support@secureage.com, or the contact form on secureage.com — attach the VT link |
| **Trapmine** | Trapmine support (contact form / support@trapmine.com) — quote the exact ML verdict |
| **VBA32** | virus@vba32.com (their sample/FP mailbox) |
Text that works — it gives the analyst everything they need in one screen:
> Subject: False positive — LiveWall 1.8.0 installer (NSIS), unsigned open-source wallpaper app
>
> Hello,
>
> Your engine reports `<verdict>` for SHA-256
> `7242a2a3fd6fc142279aecf2ea83f02501af4df3fdd1ecfe7c481bf6aa7a1764`
> (`LiveWall-Setup-1.8.0.exe`, VirusTotal: <link>).
>
> LiveWall is a free live-wallpaper app for Windows 11 (GIF/video/YouTube on the desktop). The file is
> an NSIS 3.11 installer built by our own script from our own source; it installs per-user into
> `%LOCALAPPDATA%\Programs\LiveWall`, needs no admin rights, contains no bundled offers, no telemetry,
> no obfuscation and no downloader. It is currently unsigned while certification is in progress.
>
> The detection looks like a heuristic on the NSIS solid archive rather than on behaviour. Please
> reclassify. I can provide the source, the build script, signed builds, or any further sample you
> need.
>
> Thank you.
Also worth doing once: create a **VirusTotal account** and use *Reanalyze* after reporting, and keep the
VT link in the release notes so users see the same picture you do.
### 3.3 Change the container, not the app
Two variants are built from the same source specifically for this:
| Build | Command | Why |
|---|---|---|
| `LiveWall-Setup-1.8.0.exe` | `bash installer/build-installer.sh` | solid LZMA, smallest (709 KB) |
| `LiveWall-Setup-1.8.0-lzma.exe` | `COMPRESS=lzma OUT_SUFFIX=-lzma bash installer/build-installer.sh` | no solid block, one stream per file (809 KB) |
| `LiveWall-Setup-1.8.0-zlib.exe` | `COMPRESS=zlib OUT_SUFFIX=-zlib bash installer/build-installer.sh` | no solid block, no LZMA at all (972 KB) |
| `LiveWall-1.8.0-portable.zip` | `.\build.ps1 -Task portable` | **no executable container at all** — a plain zip of the app |
Upload the variants to VirusTotal and keep the one that scores cleanest. The zlib build is the one to
try first if VBA32/Trapmine are the only remaining flags: "solid" and "lzma" are exactly what those two
score. The ZIP walks around the whole class of problems — nothing to unpack, nothing to score as a
self-extracting stub — and is a perfectly good distribution format for a 2.7 MB app that needs the
.NET 8 Desktop Runtime anyway.
Longer term, a **signed MSI (WiX)** or **MSIX** is the most "boring" container Windows offers, at the
cost of more build work. Not worth it before signing.
### 3.4 Build reputation
* Publish from **one identity, consistently**: same certificate, same publisher name in the version
resources (`Company`, `LegalCopyright`, `OriginalFilename`), same download host.
* Get real download volume — GitHub Releases, winget (`winget-pkgs`), Chocolatey, PortableApps,
Scoop. High prevalence is itself an allow-list for every ML engine.
* Keep the source public and linked from the download page. Analysts and users both use it.
* Don't re-release new hashes constantly: every build resets file reputation. Batch fixes.
### 3.5 Keep the plugin system's shape defensible
The plugin system is the most "malware-shaped" feature in the app (loading arbitrary .NET assemblies
from `%APPDATA%`). It is already as gated as a design can be, and it is worth saying so in any
submission or user-facing explanation:
* the system is **off by default** (`Plugins.Enabled = false`);
* enabling it shows a mandatory third-party-code warning that must be acknowledged;
* each plugin is hashed (SHA-256) and shows its hash, publisher and permissions in its own warning
before a single line of it runs;
* the approved hash is pinned — a changed DLL is unverified again and stops loading;
* nothing is ever downloaded or executed automatically, and there is no network component in the loader.
---
## 4. What to tell users right now
Wording that is honest and de-escalates:
> **Heads-up about antivirus warnings.** `LiveWall-Setup-1.8.0.exe` is currently flagged by 3 of 71
> engines on VirusTotal (SecureAge, Trapmine, VBA32) and is clean on the other 68, including Microsoft
> Defender, Kaspersky, BitDefender, ESET and Malwarebytes. The three are reputation/ML verdicts on an
> installer that is not yet code-signed — not behaviour detections. The installer is built from the
> source in this repository, installs per-user without admin rights, and contains no telemetry, no
> bundled offers and no downloader.
>
> * Check it yourself: VirusTotal link, SHA-256 `…` (matches the file you downloaded).
> * Vendor reports filed on 2026-11-04; these verdicts typically clear within a few days.
> * Prefer no installer at all? Use the portable ZIP: unpack it and run `LiveWall.exe`.
> * If your AV quarantines it anyway: send me the engine name and the detection name, and add an
> exclusion only if you are comfortable doing so.
Two rules for that message: never tell users to "just disable your antivirus", and always give them a
hash they can verify.
---
## 5. Checklist per release
- [ ] `.\build.ps1 -Task publish`
- [ ] `bash installer/build-installer.sh` (and optionally the `-zlib` variant)
- [ ] `.\installer\sign.ps1 -Thumbprint <cert>` — verify every file has a publisher
- [ ] Upload the installer + `LiveWall.exe` to VirusTotal, check the score
- [ ] Publish the SHA-256 of every downloadable file in the release notes
- [ ] `.\build.ps1 -Task portable` as the fallback download
- [ ] If a new engine flags it: report with the template above, then *Reanalyze* a day later
- [ ] Keep a note of who flagged which version, so you can tell whether a change helped
## 6. Tracking
| Version | File | Result | Notes |
|---|---|---|---|
| 1.8.0 | `3d472fa6…3deba` | 3/71 — SecureAge, Trapmine (ML), VBA32 (TScope.Malware-Cryptor.SB) | unsigned, solid LZMA, first seen today |
| 1.8.0 (rebuild) | `7242a2a3…a1764` | to be measured | version-info improvements; same container |
| 1.8.0 (zlib) | `78cde58c…fc4b` | to be measured | no solid block — expected to clear VBA32/Trapmine |
| 1.8.2 | `843425a9…f321c` | to be measured | input-shield + cursor fixes; same container as 1.8.0 (unsigned, solid LZMA) |
| 1.8.2 (zlib) | `cb175d79…023a9` | to be measured | non-solid variant |
| 1.8.2 portable ZIP | `016c5793…7652e` | to be measured | no installer stub at all |
| 1.8.3 | `ce642909…a5d06` | to be measured | redesigned installer (same NSIS container, same compressor) |
| 1.8.4 | `d2de043f…906e4` | to be measured | side-menu layout fix; same container |
Also worth uploading to VirusTotal, because engines inspect them once the installer has run:
`prebuilt\LiveWall.exe` (SHA-256 `7ecaa8e2ada4833ce4b84388f17da0b5605e87cb4c3348577b23e26476c953cc` before the
last rebuild) and `LiveWall.Sdk.dll`. If those come back clean while the installer does not, that alone
proves the verdict is about the NSIS container.
Portable fallback for this build: `LiveWall-1.8.0-portable.zip`,
SHA-256 `83ce64aed61232afd6e5a39d55bf8856b4630d958de37cdc42386b92f43ab506`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

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