Skip to content

Notifications ​

Lertaro.PluginSdk.Services.PluginNotificationService lets a plugin get the user's attention from the background through the host's own windows — no extra process, no OS toast channel, and no coupling to the host UI assembly. The host, not the plugin, decides how long anything stays on screen and where.

1. Entry Points ​

SignatureReturnsUse when
INotificationHandle Show(NotificationRequest request)a handle immediatelyyou want to replace or cancel this notification later
Task<NotificationResult> ShowAsync(NotificationRequest request)the end stateyou only care how it ended
bool Show(string title, string text, Action? onClick = null)whether a host accepted itlegacy two-argument shape; a false return means no host is wired, so a fallback is available

Every entry point is safe to call from a plugin's own background thread and never throws — an escaping exception would take the caller's loop down with it. When no host delegate is assigned at all (a plugin running outside the launcher) the call is a quiet no-op and the answer is a finished handle carrying Unavailable; the request is only logged, at Warn, when the host's own delegate threw.

INotificationHandle has exactly two members: Task<NotificationResult> Completion and void Dismiss(). Completion always finishes, including when nothing ever reached the screen, so an await on it cannot park a thread. Dismiss() closes a shown card as if the user had dismissed it, is a no-op once it is gone, and never reverses a result already delivered.

A plugin that needs a window it owns uses Windows.PluginWindow; one that needs an answer rather than an announcement uses PluginMessageBoxService.

2. NotificationRequest ​

PropertyDefaultBehavior
string? IdnullTwo requests with the same Id address the same notification. See Replacing by Id (§4).
string Titlestring.EmptyCard header. Ignored by BottomNotice, which has no title row.
string Messagestring.EmptyBody text. A request whose Title and Message are both blank or whitespace-only is rejected as InvalidRequest — there is nothing to show.
NotificationLevel LevelInfoInfo / Warn / Error. Drives the icon and the semantic colour, nothing else.
NotificationPosition PositionCardStackCardStack or BottomNotice. Each has its own queue and its own rules; they never block one another.
double? DurationSecondsnullnull takes the position's own default. An explicit value outside that position's range is clipped to the nearest bound, never rejected; NaN or a negative lands on the shorter end.
Action? OnClicknullRuns in addition to closing. See The Click Callback (§6).

3. The Two Positions ​

CardStack (bottom-right)BottomNotice (bottom-centre)
Formcard with title, source name, ✕ and "mark all read"one line; no title, no source, no controls
Simultaneous5 visible, the rest queued1 — a new one replaces the current immediately, with no teardown to wait out
Default duration8 s4 s
Clamped range2 – 30 s2 – 10 s
OnClickhonouredignored (nothing interactive)
Fullscreen app owns the screencollapses into the notice line, capped at 5 sunaffected

Two more pacing numbers are worth knowing when you emit a burst:

  • The host checks countdowns every 100 ms. Only that tick promotes waiting cards, at most one at a time, after the current 200 ms stack movement finishes. Automatic expiry also waits for the movement, then closes overdue cards together. Closing cards or unloading a plugin does not refill the stack during removal. Hover and session-lock pauses still apply.
  • One plugin may have at most 10 accepted card requests (on screen and waiting combined). The eleventh is dropped as QueueFull and logged. The cap is enforced on the card-admission path only: a BottomNotice request is never counted against it, because the notice is a single slot that overwrites itself. There is no global pending cap across plugins, so a plugin cannot be starved by another's burst, but neither is a runaway producer limited globally.

4. Replacing by Id ​

Giving a request an Id is how a repeating status update avoids filling the screen with copies of itself: a new request whose Id matches a visible card overwrites that card in place — same slot in the stack, not jumped to the top — and the earlier caller's handle completes as Replaced. Callers that need to know they were superseded are exactly the ones that keep the handle.

One limit to respect: deduplication is matched against the cards currently on screen, not against cards still waiting in the queue. A plugin that fires five copies of one Id in a row while the screen is full gets all five queued.

5. Results and Failure Reasons ​

NotificationResult is Succeeded plus an optional NotificationFailure. The first end state to reach an item wins; nothing rewrites it afterwards.

NotificationFailureWhen
UnavailableNo host served the request — a plugin running outside the launcher, or a host delegate that threw.
InvalidRequestTitle and Message were both blank or whitespace-only.
QueueFullThis plugin already had 10 card requests accepted.
ReplacedA newer request carried the same Id. A BottomNotice overwrite completes the superseded request this way even when neither carries an Id, because the notice is a single slot that always overwrites.
CancelledByPluginUnloadThe plugin was disabled or unloaded while its card was showing or queued.
HostShuttingDownThe launcher is closing and took the notification with it.

A notification that reached the screen and ran out its time, or was closed by the user or by Dismiss(), completes as NotificationResult.Success — those are endings, not failures.

6. The Click Callback ​

OnClick runs on any user dismissal, which the code treats as one outcome: clicking the card body and clicking ✕ take the same path. A countdown that simply expires does not run it, and neither does any of the failures in §5.

The callback is plugin code, so an exception inside it is caught, logged with the source name, and never carried back into the launcher's input pipeline. Do the work itself outside the callback if it can fail; the callback's job is to bring the user to your window (the in-repo example is Plugins/Calendar, which uses it to activate the calendar view).

7. Attribution the Plugin Cannot Forge ​

The name printed on the card comes from Assembly.GetCallingAssembly(), read in each public frame of the facade and then mapped by the host: an assembly named Lertaro.App* is shown as Lertaro, a registered plugin is shown by its managed display name, and anything else falls back to the assembly name with the Lertaro.Plugins. prefix stripped. Passing a source name as a string was deliberately not offered — it is the one attribution a plugin must not choose.

8. Placement and Surface ​

  • Which screen: the foreground window's screen, else the cursor's, else the primary. The work area is converted to DIPs through that monitor's DPI, so a card is the same physical size on a 200 %-scaled panel as on a 100 % one.
  • Stack order: bottom-right, ordered by arrival sequence; cards above a removed one re-slide (the only animation, ~200 ms with a 40 DIP entry drop).
  • Size: a card is at most half the work area tall (min(260 DIP, 50 %)), so a long message cannot eat a small monitor.
  • Drag: a card is draggable by its title bar only, and a dragged card keeps its own corner. A display-settings change re-anchors the stack, which drops a user's drag — the cheaper trade than keeping a card off-screen.
  • Surface kind: opaque, always. A card is its own window, so the window manager rounds its corners and the text renders with ClearType. The theme's WindowOpacity no longer selects the window kind, and no code applies it.
  • Topmost is always on, and the windows are excluded from Alt+Tab (WS_EX_TOOLWINDOW). Notifications do not fade: nothing animates Window.Opacity, because that would cost ClearType and a per-pixel composite on every frame. The arrival marker is a rim flash inside an already-opaque window (~750 ms).

9. Threading and Lifecycle ​

  • Call from any thread. The host serializes every notification entry point on one gate, so Show is a short, blocking call — do not drive it from a per-item hot loop; send one summary card instead.
  • Windows are presented at DispatcherPriority.Background, so a burst of notifications cannot starve the launcher's own input work.
  • While the session is locked (screen saver, Win+L), the countdown is frozen and the cards are hidden; they return when the session unlocks, still with the time they were given.
  • Disabling or unloading the plugin cancels its showing and queued cards (CancelledByPluginUnload); the launcher's orderly shutdown does the same to everything outstanding (HostShuttingDown).
  • There is no user-facing notification setting in this build: no do-not-disturb, no per-plugin switch, and the position and duration rules above are the host's, not configurable. Design requests accordingly — a plugin that talks constantly should be quieting itself, not asking the user to mute it.

10. Example ​

csharp
using Lertaro.PluginSdk.Abstractions;
using Lertaro.PluginSdk.Services;

// One card per reminder, replaced rather than repeated if it is still on screen.
var handle = PluginNotificationService.Show(new NotificationRequest
{
    Id = $"reminder-{due.Item.Id}",
    Title = TranslationService.Get("Reminder_Title"),
    Message = due.Item.Text,
    Level = NotificationLevel.Warn,
    DurationSeconds = 12,               // clipped into 2..30 by the host
    OnClick = () => CalendarView.ShowOrActivate(),
});

// Await it only where you truly need the end state; the task always completes.
_ = handle.Completion.ContinueWith(t =>
{
    if (t.Result.Failure is NotificationFailure.Replaced)
        Logger.Log($"Superseded by a newer reminder: {due.Item.Id}", LogLevel.Debug);
});

NOTE

Signatures, defaults, and limits on this page were read against PluginSdk/Abstractions/NotificationRequest.cs, PluginSdk/Abstractions/INotificationHandle.cs, PluginSdk/Services/PluginNotificationService.cs, and the host side under App/Services/Notifications/ and App/Views/Notifications/.