Choose what your plugin adds
The SDK is a .NET 10 library with no UI or database dependency. SDK 1.1 supports six capabilities under API major 1. A single entry class may implement several; declare each one in its manifest.
| Capability | Interface | Use it for |
|---|---|---|
library | ILibrarySourcePlugin | Ownership, installation and playtime observations. |
metadata | IMetadataProviderPlugin | Descriptions, release dates, genres and tags. |
artwork | IArtworkProviderPlugin | Static covers, hero backgrounds and screenshots. |
recommendations | IRecommendationFeedPlugin | A named feed shelf with scores and explanations. |
account | IPluginAccount | Device-code sign-in, connection status and sign-out through shared controls. |
game-actions | IPluginGameActions | Play or open the store page for an imported game. |
Winnow renders the results and account controls in desktop and fullscreen using its own components. Providers resolve game actions from their stable source IDs; they do not give the host command text to execute. API 1 does not expose custom screens, game-installation actions or UI replacement.
Plugins run as trusted code inside Winnow. Host services restrict their own calls, but cannot prevent a DLL from calling filesystem or network APIs directly. Install code only from authors you trust; dependency isolation is not a security sandbox.
Tutorial: your first feed provider
This example adds an “Installed and unplayed” shelf using only the supplied library snapshot. It needs no credential or network connection. Install the .NET 10 SDK and clone the Winnow repository, then run these commands from its root.
dotnet pack src/Winnow.PluginSdk -c Release -o artifacts/plugin-sdk
dotnet new classlib -n Example.Installed -f net10.0 -o examples/Example.InstalledReplace the generated project file with the following. The local SDK package is version 1.1.0; these instructions do not assume a published NuGet feed. Delete the generated Class1.cs.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Winnow.PluginSdk" Version="1.1.0"
ExcludeAssets="runtime" />
<None Update="plugin.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>Add Provider.cs. Return the supplied handle unchanged; do not turn titles or external IDs into game handles.
using Winnow.PluginSdk;
namespace Example.Installed;
public sealed class Provider : IRecommendationFeedPlugin
{
public ValueTask InitializeAsync(IPluginContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.CompletedTask;
}
public Task<IReadOnlyList<PluginRecommendation>?> GetRecommendationsAsync(
IReadOnlyList<PluginGame> library,
CancellationToken cancellationToken = default)
{
var results = new List<PluginRecommendation>();
foreach (var game in library)
{
cancellationToken.ThrowIfCancellationRequested();
if (game.Installed && game.PlaytimeMinutes == 0 && game.LastPlayedAt is null)
results.Add(new(game.Id, 0.8,
"Installed and ready, with no recorded playtime."));
}
return Task.FromResult<IReadOnlyList<PluginRecommendation>?>(results);
}
}Add plugin.json alongside the project file.
{
"id": "example-installed",
"name": "Installed and unplayed",
"version": "1.0.0",
"apiVersion": 1,
"entryAssembly": "Example.Installed.dll",
"entryType": "Example.Installed.Provider",
"description": "A local example shelf for installed games with no recorded playtime.",
"capabilities": ["recommendations"]
}dotnet restore examples/Example.Installed --source artifacts/plugin-sdk
dotnet build examples/Example.Installed -c Release --no-restoreDownload the same project file, provider source, and manifest if you prefer starting from files.
Copy the Release output into plugins/example-installed in a throwaway Winnow data directory. Include the DLL, .deps.json, manifest, and private dependencies. Do not include Winnow.PluginSdk.dll: the host supplies it.
# PowerShell, from the Winnow repository root
$demoData = Join-Path $env:TEMP ("winnow-plugin-demo-" + [guid]::NewGuid())
$package = Join-Path $demoData 'plugins/example-installed'
New-Item -ItemType Directory -Path $package -Force
Copy-Item examples/Example.Installed/bin/Release/net10.0/* $package
dotnet run --project src/Winnow.App -- --data-dir $demoData --seed-sampleClose the seeded session, then run the command below. Both --seed-sample and --no-sync suppress plugin discovery, so omit them when testing a provider. Normal startup can also import local launcher data into this throwaway library.
dotnet run --project src/Winnow.App -- --data-dir $demoDataIn either interface, open Settings → Plugins, enable the example, close Winnow, and rerun this normal-startup command with the same $demoData. Open the feed. The optional shelf appears only when the current eligible snapshot contains matching games; an empty result is expected otherwise. The plugin adds recommendations, never ownerships.
Manifest reference
The host reads plugin.json before executing code. The file may be at most 64 KiB. Property names are case-insensitive; IDs and capability values are case-sensitive. Manifest validation source.
| Field | Rules |
|---|---|
id | Required, 1–64 characters; pattern ^[a-z0-9][a-z0-9.-]*$. Keep it stable: it namespaces settings, cache and provider identity. |
name, version | Nonblank name, up to 100 characters. Version must parse as .NET System.Version, such as 1.0.0; prerelease suffixes are not supported. |
apiVersion | Must equal 1 (PluginApi.Version). |
entryAssembly | DLL filename in the package root, at most 180 characters; no directory separators or colon. |
entryType | Fully qualified public, concrete class name, at most 256 characters. It must have a public parameterless constructor and implement IPlugin plus its declared capabilities. |
description, website | Optional description up to 2,000 characters. Website must be an absolute HTTPS URL without embedded credentials, up to 2,048 characters. |
capabilities | One to six distinct values from library, metadata, artwork, recommendations, account, game-actions. |
settings | At most 32 PluginSettingDefinition entries; defaults to empty. |
network | PluginNetworkOptions; defaults to no allowed hosts, 1 request/second, 2 retries, 2 MiB responses, 90-second timeout. |
Each setting has a unique key with the same syntax as the plugin ID, a nonblank label up to 120 characters, optional help up to 1,000 characters, and optional HTTPS setupUrl. The flags below default to false. Check missing credentials in your provider as well.
| Setting flag | Host behavior |
|---|---|
secret | Use protected credential storage and a masked editor. Saved values never appear in settings snapshots. |
required | The editor requires a value when saving; providers still handle unavailable credentials. |
isBoolean | Render a toggle. Cannot be combined with secret. |
isAdvanced | Place the editable field under Show advanced settings on desktop and fullscreen. Collapsing it preserves its value when saving. |
managedByPlugin | Hide a credential maintained by the provider, such as a refresh token, from the editors. Requires secret: true. |
"settings": [
{ "key": "apikey", "label": "API key", "secret": true, "required": true,
"help": "Create a read-only key for artwork requests.",
"setupUrl": "https://api.example.com/account" }
],
"network": {
"allowedHosts": ["api.example.com", "images.example.com"],
"requestsPerSecond": 1,
"maxRetries": 2,
"maxResponseBytes": 2097152,
"timeoutSeconds": 90
}Allowed hosts are exact DNS names, at most 32, with no scheme, path, wildcard, trailing dot or IP literal. The valid request rate is 0.05–4 per second, retries 0–2, response bound 1 byte–32 MiB, and timeout 1–120 seconds.
Provider interfaces and data types
All contracts live in Winnow.PluginSdk. Every method accepts an optional CancellationToken; pass it through to storage, HTTP and your own asynchronous operations. These are the complete provider signatures (default token arguments omitted for space).
IPlugin:
ValueTask InitializeAsync(IPluginContext context, CancellationToken ct)
ILibrarySourcePlugin : IPlugin:
Task<IReadOnlyList<PluginLibraryGame>?> GetLibraryAsync(CancellationToken ct)
IMetadataProviderPlugin : IPlugin:
Task<PluginMetadata?> GetMetadataAsync(PluginGame game, CancellationToken ct)
IArtworkProviderPlugin : IPlugin:
Task<IReadOnlyList<PluginArtwork>?> GetArtworkAsync(PluginGame game, CancellationToken ct)
IRecommendationFeedPlugin : IPlugin:
Task<IReadOnlyList<PluginRecommendation>?> GetRecommendationsAsync(
IReadOnlyList<PluginGame> library, CancellationToken ct)
IPluginAccount : IPlugin:
Task<PluginAccountStatus> GetAccountStatusAsync(CancellationToken ct)
Task<PluginSignInChallenge?> BeginSignInAsync(CancellationToken ct)
Task<PluginSignInResult> PollSignInAsync(string attemptId, CancellationToken ct)
Task SignOutAsync(CancellationToken ct)
Task CancelSignInAsync(string attemptId, CancellationToken ct)
IPluginGameActions : IPlugin:
Task<PluginGameActionResult> ExecuteGameActionAsync(string sourceId,
PluginGameActionKind action, CancellationToken ct)| Type | Constructor and properties |
|---|---|
PluginGame | Constructor: (string Id, string Title, IReadOnlyDictionary<string, string> ExternalIds). Init properties: bool Installed, long PlaytimeMinutes, DateTimeOffset? LastPlayedAt, IReadOnlyList<string> Genres and Tags (empty by default). |
PluginLibraryGame | Constructor: (string SourceId, string Title). Init properties: bool TitleIsProvisional, ExternalIds (string dictionary, empty by default), string? AccountRef, string? InstallPath, bool? Installed, long? PlaytimeMinutes, DateTimeOffset? LastPlayedAt, DateTimeOffset? AcquiredAt, string? LibrarySourceLabel, IReadOnlyList<PluginGameActionKind> Actions (empty by default). |
PluginMetadata | Parameterless record. Init properties: string? Summary, DateTimeOffset? ReleaseDate, IReadOnlyList<string> Genres and Tags (empty by default). |
PluginArtwork | Constructor: (string Id, string Url, int Width, int Height). Init properties: PluginArtworkKind Kind (defaults to Background), string? ImageType, bool Animated, bool Transparent. Kind values: Background, Cover, Screenshot. |
PluginRecommendation | Constructor: (string GameId, double Score, string Reason). Use a supplied handle, a finite score in [0, 1], and a nonblank explanation of at most 300 characters without control characters. |
PluginAccountStatus | Constructor: (bool Connected, string Message). The shared UI uses its own status copy based on the connection state. |
PluginSignInChallenge | Constructor: (string AttemptId, string VerificationUrl, string UserCode, DateTimeOffset ExpiresAt, int PollIntervalSeconds). Only the verification address and user code are shown; the attempt ID stays opaque. |
PluginSignInResult | Constructor: (PluginSignInState State, string Message). States: Pending, SlowDown, Connected, Failed. The host uses fixed feedback instead of displaying the provider’s message. |
PluginGameActionResult | Constructor: (bool HandedOff). True means the provider handed the action to its target, not that the game started successfully. PluginGameActionKind values: Play, OpenStore. |
Read the exact data declarations and account and game-action declarations. Records are source-attributed observations; the host controls application and presentation.
Identity, results and limits
PluginGame.Id is an opaque handle for the current request, not a persistent foreign key. Metadata and artwork handles refer to original works; feed handles refer to eligible owned entries. Use ExternalIds for external service lookups, and version any persistent cache format you own.
Library imports
Keep SourceId stable within your plugin. Only known steam, epic and gog IDs can establish external joins, and all existing matches must agree. Titles never create automatic joins. Missing results never remove ownerships; playtime imports are lower-bound observations.
Set TitleIsProvisional when the title is only an unresolved identifier; a later import can replace that placeholder. Use LibrarySourceLabel to explain inclusion, such as installed games or played history, without claiming a purchase. Labels are limited to 250 characters without control characters and appear in both detail views. Advertise supported Actions separately.
Imports publish before optional metadata/artwork and queue merge suggestions across stores. Confirmed and rejected merge decisions remain intact. Desktop Merges and fullscreen Library tools → Possible identity matches also offer Refresh suggestions; this local pass does not fetch another inventory or accept suggestions.
The host considers the first 50,000 entries, deduplicates by SourceId, and skips invalid records. Source IDs must be nonblank, at most 256 characters and have no control characters. Titles must be nonblank and at most 500 characters; playtime cannot be negative. Only fully qualified installation paths are used. Up to 32 external IDs are considered; each accepted value is nonblank, at most 256 characters and has no control characters.
Metadata
Summary and release year fill missing automatic fields; user overrides remain authoritative. Genre and tag assignments retain your source identity. Summary is bounded to 20,000 characters, release years to 1950–2200, and the first 100 names per genre/tag list are considered. Names must be nonblank, at most 100 characters and contain no control characters; they are trimmed and deduplicated.
Artwork
Return static HTTPS assets on declared hosts, with accurate dimensions. The first 500 results are considered. Each dimension must be 1–8,192 pixels, total area at most 32 Mi pixels, and URL at most 2,048 characters. Animated assets are ignored; transparent covers are not selected. Duplicates use kind and URL; the host generates its own hashed cache identity instead of trusting an arbitrary remote ID. Image downloads have a separate 32 MiB bound.
A null library or artwork result means unavailable and preserves previous observations. An empty list confirms no results. Empty artwork clears this provider’s backgrounds and screenshots, but does not clear an assigned cover. A nonempty artwork response containing only invalid candidates cannot erase previous valid observations. User-selected backgrounds take precedence over automatic artwork ordering.
Recommendations
The supplied snapshot excludes suppressed games, hidden account entries, non-games, provisional titles and retired/derelict entries. Confirmed duplicates appear once. The host rejects unknown handles and invalid scores/reasons, sorts by score, deduplicates, and retains up to ten candidates. The shared shelf has six primary candidates and four reserves; desktop currently presents five cards. A null or empty response yields no shelf and does not reuse an older result.
Optional shelves append to the built-in feed and cannot bring a suppressed game back. A shared five-second budget covers snapshot reads, queueing and provider execution across optional feeds. Return promptly, preferably from cache. Completed providers survive another provider’s timeout; obsolete results are discarded after navigation, a newer generation, or feedback changes.
Account connection and game actions
Account connection
Declare account and implement IPluginAccount to use the shared Sign in, Cancel sign-in and Sign out controls on desktop and fullscreen. Keep each method short: the host owns the wait between polling calls, and every call still has the provider deadline.
BeginSignInAsync returns a challenge or null when unavailable. Keep device codes and access tokens inside the provider; return an opaque attempt ID containing no credential. The host shows only the user code and a verification address whose HTTPS host is declared in network.allowedHosts. URLs require the default HTTPS port and no embedded credentials.
Attempt IDs are 1–256 characters without control characters. User codes are 1–64 ASCII letters, digits, spaces or hyphens and must include a letter or digit. Challenges expire within one hour, with an initial polling interval of 1–60 seconds. SlowDown adds five seconds to the interval, capped at 300 seconds. The host stops on connection, failure, expiry or cancellation and asks the provider to cancel unsuccessful attempts.
Persist refresh credentials through declared managedByPlugin secrets before reporting Connected. Remove them on sign-out and invalidate the account’s in-memory state and caches. Connection and sign-out queue a refresh; previously imported games remain. Provider messages are not a route for custom UI copy: the host displays fixed status text.
The Xbox provider demonstrates this flow. PlayStation uses a user-entered NPSSO secret with Save and Remove saved secret controls because its connection does not follow this device-code contract.
Game actions
Declare game-actions, implement IPluginGameActions, and list Play or OpenStore in each imported game’s Actions. Play also requires an installed entry. Both UI surfaces use the same advertised actions and an active provider.
Before dispatch, Winnow rechecks the local ownership, provider, source ID, advertised action and installation state where needed. The provider resolves that known source ID to a current target and returns HandedOff. Do not trust cached paths or turn a source ID into arbitrary command text. Console-only entries can omit actions while retaining library history and metadata.
Settings, secrets and cache
Retain the IPluginContext passed to initialization. Its PluginId, Settings, Secrets, Cache, and Http properties are scoped to your manifest ID.
IPluginSettings:
ValueTask<string?> GetAsync(string key, CancellationToken ct = default)
ValueTask SetAsync(string key, string? value, CancellationToken ct = default)
IPluginSecrets:
ValueTask<string?> GetAsync(string key, CancellationToken ct = default)
ValueTask SetAsync(string key, string value, CancellationToken ct = default)
ValueTask RemoveAsync(string key, CancellationToken ct = default)
IPluginCache:
ValueTask<PluginCacheEntry?> GetAsync(string key, CancellationToken ct = default)
ValueTask SetAsync(string key, PluginCacheEntry entry, CancellationToken ct = default)
public sealed record PluginCacheEntry(byte[] Payload, DateTimeOffset ExpiresAt);Settings access is limited to declared non-secret keys; values may be at most 4,096 characters. SDK 1.1 adds secret writes and removal for declared secret keys. Secret values are limited to 4,096 characters without control characters. Re-read credentials when making requests so saved changes take effect without rebuilding your plugin. Writing through the storage API does not itself request a library refresh.
User-editable secrets appear as masked fields. Declare secret: true, managedByPlugin: true for refresh credentials maintained by your plugin; these stay out of the editors and read only protected stored values. Windows persists credentials using current-user DPAPI with plugin/key-specific entropy. Other platforms refuse persisted secret writes; the SDK’s default write/remove implementations also throw NotSupportedException for hosts or test contexts that have not implemented them.
For user-editable secrets, environment configuration Plugins__<plugin-id>__<key> supplies a fallback when no usable stored secret exists. Removing the saved value does not remove that configuration. Managed secrets have no configuration fallback, so signing out cannot resurrect a configured refresh token. Never put device codes or tokens in ordinary settings, caches, logs or account messages.
Cache writes allow keys up to 512 characters and payloads up to 2 MiB. Expired entries remain readable: compare ExpiresAt yourself, attempt refresh, and use stale data only when appropriate for your provider. Use a versioned key such as summary:v1:steam:123 or version the serialized payload. A null cache entry is a miss, not a negative provider result.
HTTP requests and resilient providers
IPluginHttp:
Task<PluginHttpResponse> SendAsync(PluginHttpRequest request,
CancellationToken cancellationToken = default)
public sealed record PluginHttpRequest(string Url)
{
public string Method { get; init; } = "GET";
public IReadOnlyDictionary<string, string> Headers { get; init; }
= new Dictionary<string, string>();
public byte[]? Body { get; init; }
public string? ContentType { get; init; }
}
public sealed record PluginHttpResponse(int StatusCode, byte[] Body,
IReadOnlyDictionary<string, string> Headers);Use context.Http for API calls. Requests require HTTPS on port 443 and an exact declared host, without URL credentials or fragments. Only uppercase GET, POST and HEAD are supported. Request bodies are limited to 2 MiB. Supply the content type through ContentType; Host, Proxy-Authorization, Connection, Transfer-Encoding and Content-Length headers are rejected.
Redirects and cookies are disabled. Response limits apply to declared length and streamed bytes. Each plugin shares a rate budget across its requests, retries and artwork downloads. The host retries transport failures, HTTP 408, 429 and 5xx responses within the manifest budget, using exponential backoff with jitter and bounded Retry-After delays. Make retryable POST requests idempotent.
SendAsync returns the final HTTP status, including unsuccessful statuses. Check StatusCode before parsing. Handle authentication failures by returning unavailable data rather than caching a false empty result. Cache confirmed “not found” responses separately from transient errors, and pass cancellation through every await.
// Within an initialized provider; context is the retained IPluginContext.
var key = await context.Secrets.GetAsync("apikey", cancellationToken);
if (string.IsNullOrWhiteSpace(key)) return null;
var response = await context.Http.SendAsync(
new PluginHttpRequest("https://api.example.com/games/123")
{
Headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer " + key
}
}, cancellationToken);
if (response.StatusCode != 200) return null;
// Deserialize response.Body, validate it, and return your capability's result.Lifecycle, failures and cleanup
- Winnow validates packages and manifests, resolving bundled IDs before user packages. Manually added packages begin disabled unless a saved preference enables them.
- Enabled entry classes are constructed and initialized off the UI thread. Initialization has a 30-second deadline. New first-party packages installed from the website can load immediately; changes to existing packages require restart.
- Library imports start after discovery, publish committed data, and queue merge suggestions and separate metadata/artwork work. They do not wait for other stores’ enrichment. Feed providers run alongside the built-in feed. Settings saves, account connection changes and Refresh queue new background work.
- Calls to a given plugin are serialized by the host. Ordinary provider calls have a 120-second deadline; feed calls also face the shorter shared budget described above.
- On shutdown, pending installs are cancelled and drained before catalogue disposal. The host calls
IAsyncDisposableif implemented, otherwiseIDisposable, with a two-second cleanup wait. An initializer that completes after cancellation cannot become active.
An ordinary exception fails the operation and produces a fixed diagnostic without exception text, which could contain credentials. An invocation timeout disables the plugin for the session. Cancellation is forwarded; if work remains unfinished when the caller cancels, the host can also disable the instance. Restart to retry. Cancellation is cooperative: a hung native call or malicious code is not isolated by these deadlines.
Keep constructors and initialization light. Do not spawn unbounded background work, hold UI references, or depend on a specific invocation thread. Enable/disable changes take effect after restart; there is no hot reload.
Package, install and version
Keep the entry DLL, its .deps.json, plugin.json and private dependencies together. The host shares its SDK assembly and resolves dependencies within the plugin’s package, not from other plugin directories. For project references use Private=false; for NuGet references use ExcludeAssets=runtime.
example-installed/
plugin.json
Example.Installed.dll
Example.Installed.deps.json
...private dependencies, if anyDistribute a ZIP with these files at its root or inside one enclosing folder. Users drop it into the data directory’s plugins folder and restart, then enable the package and restart again. Successful archives move to plugins/.archives. Installation rejects unsafe paths, links, conflicting filenames and invalid packages.
Archive limits are 2,048 entries, 256 MiB compressed and unpacked, and paths of at most 1,024 characters and 32 components. Imports never overwrite an existing package or replace a bundled plugin. For updates, close Winnow and replace the package directory, or remove it before importing the replacement ZIP. Uninstall by closing Winnow and removing the directory. Imported facts and cached metadata remain.
The official plugins catalogue offers ZIPs and Install in Winnow for Xbox, PlayStation and SteamGridDB. Release CI builds these packages with a winnow-plugins.json catalogue and checksums. Links select a known plugin ID and exact release tag: winnow://plugins/install?id=psn&release=v0.2.0. The app verifies the official release, hashes, sizes and package identity before installing and enabling a new provider. Existing packages and their enabled state are preserved. This download service accepts only first-party IDs; distribute third-party packages through the ZIP flow above.
Keep the plugin ID stable when updating and increment your manifest version. SDK package version 1.1.0, API major 1 and SDK assembly version 1.0.0.0 are separate from the Winnow application version and your plugin’s manifest version. Using SDK 1.1 features requires a host that supplies them; the unchanged API major alone does not guarantee support. The official release catalogue can declare minimumSdkVersion, which the website installer checks; it is not a field of the SDK’s PluginManifest. Automatic plugin updates and general compatibility negotiation are not supported.
Test and troubleshoot
Unit-test providers with a fake IPluginContext: supply deterministic settings, cache entries and HTTP responses, and call the capability methods directly. Test cancellation, empty and null results, credential changes, stale-cache fallback, malformed payloads and rate-limit responses. For feed tests, include unknown IDs and boundary scores; for library sources, keep stable SourceIds across refreshes.
Then test the complete package through the real loader using a separate --data-dir. Verify enable/restart, refresh, disable/restart, both desktop and fullscreen, offline behavior, and a clean install. Never use your personal library for destructive or experimental imports. The source repository’s plugin host tests demonstrate real loader and network-boundary checks.
| Symptom | Check |
|---|---|
| Package not listed | Use Settings → Plugins → Open plugins folder for the active data directory. Ensure plugin.json is inside the package folder. Restart without --seed-sample or --no-sync, which suppress discovery, and inspect installation diagnostics. |
| Could not start | Check entry filename/type, public parameterless constructor, declared interfaces, net10.0 target, compatible SDK and included private dependencies. |
| Enabled but not running | Restart after toggling. The loaded list describes this session, not the pending enable state. Check for initialization failure or timeout. |
| No shelf | Verify eligible matching entries, supplied GameIds, finite scores and nonblank reasons. Return within the shared five-second feed budget. |
| Requests fail | Check exact declared host, port 443, disabled redirects, method spelling, credentials, status code and response bounds. |
| Undeclared setting | Match the manifest key exactly and use Secrets for secret keys, Settings for non-secret keys. |
| Secret write fails | Use a host with protected credential writes, or implement them explicitly in the test context. Do not fall back to plaintext storage. Managed credentials cannot come from environment configuration. |
| Sign-in challenge rejected | Check the verification host, code format, opaque attempt ID, polling interval and expiry described above. Keep provider credentials out of the challenge. |
| Old artwork stays | Null preserves prior results. A confirmed empty list clears only this provider’s backgrounds/screenshots. User choices and assigned covers have separate precedence. |
| Replacement ZIP rejected | Close Winnow and replace/remove the old package directory first. Imports deliberately do not overwrite installed packages. |
For implementation detail, read the repository plugin guide, plugin-system walkthrough, and SDK source. These contracts are the shared extension boundary for both Winnow interfaces.