Skip to content

Commit 79fa30a

Browse files
committed
Implement native mode IPC integrations
1 parent 9498972 commit 79fa30a

19 files changed

Lines changed: 638 additions & 16 deletions

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,40 @@ Built-in sound profiles:
7979

8080
Quiet Hours can suppress sounds, with an optional critical notification override. The Test Lab section in the app includes a rule tester for sample notification payloads.
8181

82+
## Native Mode Integrations
83+
84+
ToastDeck Native Mode lets local tools create first-class ToastDeck cards through the CLI and named pipe IPC.
85+
86+
CLI examples:
87+
88+
```powershell
89+
toastdeck send --title "Build failed" --body "main branch failed on tests" --source "GitHub Actions" --severity critical
90+
toastdeck test critical
91+
toastdeck pause 30m
92+
toastdeck rules export
93+
toastdeck rules import --file rules.json
94+
```
95+
96+
The CLI talks to the running ToastDeck app through the local named pipe `ToastDeck.NativeMode`. If the app is not running or does not respond, the CLI returns a clear error instead of silently dropping the notification.
97+
98+
Protocol activation design:
99+
100+
```text
101+
toastdeck://send?source=Build&title=Build%20failed&body=Tests%20failed&severity=critical
102+
```
103+
104+
The MSIX manifest declares the `toastdeck` protocol. Full activation routing from Windows into the running app is planned after the single-instance app model is added.
105+
106+
Optional localhost API skeleton:
107+
108+
```http
109+
POST http://127.0.0.1:17387/v1/notifications
110+
Content-Type: application/json
111+
Authorization: Bearer <token>
112+
```
113+
114+
The localhost API is disabled by default. It must stay bound to localhost, require a token, and add rate limiting before it is enabled.
115+
82116
## Installation
83117

84118
ToastDeck is not released yet.

SECURITY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,13 @@ Please report security issues privately through GitHub Security Advisories when
1515
- No arbitrary command execution from external payloads by default.
1616
- Optional local HTTP APIs must bind to localhost, be disabled by default, require a token, and be rate-limited.
1717
- Notification body text must not be logged unless diagnostic mode is explicitly enabled.
18+
19+
## Native Mode Integrations
20+
21+
ToastDeck Native Mode accepts local payloads through a named pipe used by the CLI. Treat external payloads as untrusted:
22+
23+
- Do not execute command actions from payloads by default.
24+
- Do not trust URLs blindly.
25+
- Keep the optional localhost API disabled by default.
26+
- If the localhost API is enabled in the future, bind only to `127.0.0.1` or `localhost`, require a token, and rate limit requests.
27+
- Do not include notification bodies in logs.

docs/docs.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!doctype html>
22
<html lang="en">
33
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ToastDeck Docs</title><link rel="stylesheet" href="styles.css"></head>
4-
<body><header class="nav"><strong>ToastDeck</strong><nav><a href="index.html">Home</a></nav></header><main><h1>Docs</h1><ul><li>Getting started</li><li>Grant notification permission</li><li>Configure persistent deck</li><li>Configure corner badge</li><li>Create rules</li><li>Sound settings</li><li>Privacy mode</li><li>CLI usage</li><li>Troubleshooting</li></ul></main></body>
4+
<body><header class="nav"><strong>ToastDeck</strong><nav><a href="index.html">Home</a><a href="integrations.html">Integrations</a></nav></header><main><h1>Docs</h1><ul><li>Getting started</li><li>Grant notification permission</li><li>Configure persistent deck</li><li>Configure corner badge</li><li>Create rules</li><li>Sound settings</li><li>Privacy mode</li><li><a href="integrations.html">CLI and Native Mode usage</a></li><li>Troubleshooting</li></ul></main></body>
55
</html>

docs/integrations.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ToastDeck Integrations</title><link rel="stylesheet" href="styles.css"></head>
4+
<body>
5+
<header class="nav"><strong>ToastDeck</strong><nav><a href="index.html">Home</a><a href="docs.html">Docs</a><a href="privacy.html">Privacy</a></nav></header>
6+
<main>
7+
<h1>Native Mode Integrations</h1>
8+
<p>ToastDeck Native Mode lets local scripts, build tools, and automation send persistent ToastDeck cards.</p>
9+
<h2>CLI</h2>
10+
<pre><code>toastdeck send --title "Build failed" --body "Tests failed" --source "GitHub Actions" --severity critical
11+
toastdeck test critical
12+
toastdeck pause 30m
13+
toastdeck rules export
14+
toastdeck rules import --file rules.json</code></pre>
15+
<h2>Named Pipe</h2>
16+
<p>The CLI sends JSON commands to the running ToastDeck app through the local named pipe <code>ToastDeck.NativeMode</code>. If the app is not running, the CLI returns a clear error.</p>
17+
<h2>Protocol Activation</h2>
18+
<pre><code>toastdeck://send?source=Build&amp;title=Build%20failed&amp;body=Tests%20failed&amp;severity=critical</code></pre>
19+
<p>The protocol is declared in the app manifest. Runtime activation routing is planned with the single-instance app model.</p>
20+
<h2>Localhost API</h2>
21+
<p>The localhost API skeleton is present but disabled by default. Before it can be enabled, it must bind only to localhost, require a token, and enforce rate limiting.</p>
22+
<h2>Security Notes</h2>
23+
<ul>
24+
<li>Do not send secrets or OTPs unless you are comfortable displaying them locally.</li>
25+
<li>The local API must never listen on external network interfaces by default.</li>
26+
<li>Command actions from external payloads are not trusted and are not executed by default.</li>
27+
<li>Notification bodies must not be written to logs by default.</li>
28+
</ul>
29+
</main>
30+
</body>
31+
</html>

src/ToastDeck.App/MainWindow.xaml.cs

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using ToastDeck.App.Windowing;
88
using ToastDeck.App.Windows;
99
using ToastDeck.Core.Domain;
10+
using ToastDeck.Core.Integrations;
1011
using ToastDeck.Core.Interfaces;
1112
using ToastDeck.Core.Notifications;
1213
using ToastDeck.Core.Rules;
@@ -31,8 +32,11 @@ public sealed partial class MainWindow : Window
3132
private readonly CornerBadgeWindow badgeWindow = new();
3233
private readonly IRuleEngine ruleEngine = new ToastDeckRuleEngine();
3334
private readonly NotificationSoundService soundService = new();
34-
private readonly IReadOnlyList<Rule> rules = CreateDefaultRules();
35+
private readonly LocalApiService localApiService = new();
36+
private readonly List<Rule> rules = [.. CreateDefaultRules()];
37+
private readonly CancellationTokenSource appLifetime = new();
3538
private UserSettings settings = new();
39+
private DateTimeOffset? pausedUntil;
3640
private bool settingsLoaded;
3741

3842
public MainWindow()
@@ -50,6 +54,7 @@ public MainWindow()
5054
private async Task InitializeAsync()
5155
{
5256
await RefreshAccessStatusAsync();
57+
_ = Task.Run(() => new ToastDeckPipeServer(HandlePipeRequestAsync).RunAsync(appLifetime.Token));
5358
await SyncNotificationsAsync();
5459
listenerService.StartForegroundListener();
5560
}
@@ -145,6 +150,115 @@ private void ApplySettingsFromControls()
145150
UpdateOverlays();
146151
}
147152

153+
private async Task<ToastDeckPipeResponse> HandlePipeRequestAsync(ToastDeckPipeRequest request, CancellationToken cancellationToken)
154+
{
155+
return request.Command.ToLowerInvariant() switch
156+
{
157+
"notification.send" => await HandleNativeNotificationAsync(request.Notification, cancellationToken),
158+
"app.pause" => HandlePause(request.Duration),
159+
"rules.export" => HandleRulesExport(),
160+
"rules.import" => HandleRulesImport(request.RulesJson),
161+
_ => ToastDeckPipeResponse.Fail($"Unknown IPC command: {request.Command}")
162+
};
163+
}
164+
165+
private async Task<ToastDeckPipeResponse> HandleNativeNotificationAsync(
166+
NativeNotificationPayload? payload,
167+
CancellationToken cancellationToken)
168+
{
169+
if (payload is null)
170+
{
171+
return ToastDeckPipeResponse.Fail("Notification payload is required.");
172+
}
173+
174+
if (string.IsNullOrWhiteSpace(payload.Title))
175+
{
176+
return ToastDeckPipeResponse.Fail("Notification title is required.");
177+
}
178+
179+
await dispatcherQueue.EnqueueAsync(async () =>
180+
{
181+
var notification = NotificationFactory.Create(new NotificationDraft(
182+
payload.Source,
183+
payload.Title,
184+
payload.Body,
185+
payload.Severity,
186+
NotificationSourceType.Cli));
187+
188+
if (!string.IsNullOrWhiteSpace(payload.GroupKey))
189+
{
190+
notification = notification with { GroupKey = payload.GroupKey };
191+
}
192+
193+
var updated = ApplyRuleResult(notification);
194+
if (RuleDisplayFor(updated) == RuleDisplayBehavior.Ignore)
195+
{
196+
return;
197+
}
198+
199+
await notificationRepository.SaveAsync(updated);
200+
notifications.Insert(0, updated);
201+
UpdateOverlays();
202+
});
203+
204+
return ToastDeckPipeResponse.Ok("Notification sent to ToastDeck.");
205+
}
206+
207+
private ToastDeckPipeResponse HandlePause(string? duration)
208+
{
209+
try
210+
{
211+
var pauseFor = PauseDurationParser.Parse(duration ?? "30m");
212+
pausedUntil = DateTimeOffset.Now.Add(pauseFor);
213+
_ = dispatcherQueue.TryEnqueue(UpdateOverlays);
214+
return ToastDeckPipeResponse.Ok($"ToastDeck paused until {pausedUntil:O}.");
215+
}
216+
catch (ArgumentException ex)
217+
{
218+
return ToastDeckPipeResponse.Fail(ex.Message);
219+
}
220+
}
221+
222+
private ToastDeckPipeResponse HandleRulesExport()
223+
{
224+
var json = JsonSerializer.Serialize(rules, new JsonSerializerOptions(JsonSerializerDefaults.Web)
225+
{
226+
WriteIndented = true
227+
});
228+
229+
return ToastDeckPipeResponse.Ok("Rules exported.", json);
230+
}
231+
232+
private ToastDeckPipeResponse HandleRulesImport(string? rulesJson)
233+
{
234+
if (string.IsNullOrWhiteSpace(rulesJson))
235+
{
236+
return ToastDeckPipeResponse.Fail("Rules JSON is required.");
237+
}
238+
239+
try
240+
{
241+
var imported = JsonSerializer.Deserialize<List<Rule>>(rulesJson, new JsonSerializerOptions(JsonSerializerDefaults.Web)
242+
{
243+
PropertyNameCaseInsensitive = true
244+
});
245+
246+
if (imported is null)
247+
{
248+
return ToastDeckPipeResponse.Fail("Rules JSON did not contain a rule list.");
249+
}
250+
251+
rules.Clear();
252+
rules.AddRange(imported);
253+
_ = dispatcherQueue.TryEnqueue(UpdateOverlays);
254+
return ToastDeckPipeResponse.Ok($"Imported {rules.Count} rule(s).");
255+
}
256+
catch (JsonException ex)
257+
{
258+
return ToastDeckPipeResponse.Fail($"Rules JSON was invalid: {ex.Message}");
259+
}
260+
}
261+
148262
private async Task MarkReadAsync(NotificationRecord notification)
149263
{
150264
var updated = NotificationStateMachine.MarkRead(notification, DateTimeOffset.UtcNow);
@@ -316,6 +430,19 @@ private NotificationRecord ApplyRuleResult(NotificationRecord notification)
316430
private void UpdateOverlays()
317431
{
318432
UpdateDeckSummary();
433+
if (pausedUntil is { } pauseEnd)
434+
{
435+
if (pauseEnd > DateTimeOffset.Now)
436+
{
437+
deckWindow.HideDeck();
438+
badgeWindow.SetUnreadCount(0);
439+
UpdateDeckSummary($"ToastDeck is paused until {pauseEnd:t}.");
440+
return;
441+
}
442+
443+
pausedUntil = null;
444+
}
445+
319446
var unread = notifications
320447
.Where(notification => (notification.State == NotificationState.Unread || notification.IsPinned) &&
321448
RuleDisplayFor(notification) != RuleDisplayBehavior.InboxOnly)
@@ -405,6 +532,7 @@ private void MainWindow_Closed(object sender, WindowEventArgs args)
405532
{
406533
listenerService.StopForegroundListener();
407534
listenerService.NotificationsChanged -= ListenerService_NotificationsChanged;
535+
appLifetime.Cancel();
408536
foreach (var notification in notifications)
409537
{
410538
soundService.Stop(notification.Id);

src/ToastDeck.App/Package.appxmanifest

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
<uap5:Extension Category="windows.startupTask">
3535
<uap5:StartupTask TaskId="ToastDeckStartup" Enabled="false" DisplayName="ToastDeck" />
3636
</uap5:Extension>
37+
<uap:Extension Category="windows.protocol">
38+
<uap:Protocol Name="toastdeck">
39+
<uap:DisplayName>ToastDeck Native Mode</uap:DisplayName>
40+
</uap:Protocol>
41+
</uap:Extension>
3742
</Extensions>
3843
</Application>
3944
</Applications>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Microsoft.UI.Dispatching;
2+
3+
namespace ToastDeck.App.Services;
4+
5+
public static class DispatcherQueueExtensions
6+
{
7+
public static Task EnqueueAsync(this DispatcherQueue dispatcherQueue, Func<Task> action)
8+
{
9+
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
10+
11+
if (!dispatcherQueue.TryEnqueue(async () =>
12+
{
13+
try
14+
{
15+
await action();
16+
completion.SetResult();
17+
}
18+
catch (Exception ex)
19+
{
20+
completion.SetException(ex);
21+
}
22+
}))
23+
{
24+
completion.SetException(new InvalidOperationException("Failed to enqueue work on the UI dispatcher."));
25+
}
26+
27+
return completion.Task;
28+
}
29+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using ToastDeck.Core.Integrations;
2+
3+
namespace ToastDeck.App.Services;
4+
5+
public sealed class LocalApiService
6+
{
7+
public LocalApiOptions Options { get; private set; } = new();
8+
9+
public bool IsRunning => false;
10+
11+
public void Configure(LocalApiOptions options)
12+
{
13+
Options = options;
14+
}
15+
16+
public Task StartAsync(CancellationToken cancellationToken = default)
17+
{
18+
if (!Options.IsEnabled)
19+
{
20+
return Task.CompletedTask;
21+
}
22+
23+
throw new NotSupportedException("The localhost API skeleton is present but disabled until token auth and rate limiting are implemented.");
24+
}
25+
}

0 commit comments

Comments
 (0)