commit e13f197ff066706543b577892cc79ac4e1610270 Author: Luis von der Eltz Date: Sun Jan 31 15:40:50 2021 +0100 Cleanup & intro OSX diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6949a6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,527 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/visualstudio,dotnetcore,csharp +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudio,dotnetcore,csharp + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*[.json, .xml, .info] + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ + +# Common node modules locations +/node_modules +/wwwroot/node_modules + +### VisualStudio ### + +# User-specific files + +# User-specific files (MonoDevelop/Xamarin Studio) + +# Mono auto generated files + +# Build results + +# Visual Studio 2015/2017 cache/options directory +# Uncomment if you have tasks that create the project's static files in wwwroot + +# Visual Studio 2017 auto generated files + +# MSTest test Results + +# NUnit + +# Build Results of an ATL Project + +# Benchmark Results + +# .NET Core + +# StyleCop + +# Files built by Visual Studio + +# Chutzpah Test files + +# Visual C++ cache files + +# Visual Studio profiler + +# Visual Studio Trace Files + +# TFS 2012 Local Workspace + +# Guidance Automation Toolkit + +# ReSharper is a .NET coding add-in + +# TeamCity is a build add-in + +# DotCover is a Code Coverage Tool + +# AxoCover is a Code Coverage Tool + +# Coverlet is a free, cross platform Code Coverage Tool + +# Visual Studio code coverage results + +# NCrunch + +# MightyMoose + +# Web workbench (sass) + +# Installshield output folder + +# DocProject is a documentation generator add-in + +# Click-Once directory + +# Publish Web Output +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted + +# NuGet Packages +# NuGet Symbol Packages +# The packages folder can be ignored because of Package Restore +# except build/, which is used as an MSBuild target. +# Uncomment if necessary however generally it will be regenerated when needed +# NuGet v3's project.json files produces more ignorable files + +# Microsoft Azure Build Output + +# Microsoft Azure Emulator + +# Windows Store app package directories and files + +# Visual Studio cache files +# files ending in .cache can be ignored +# but keep track of directories ending in .cache + +# Others + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) + +# RIA/Silverlight projects + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) + +# SQL Server files + +# Business Intelligence projects + +# Microsoft Fakes + +# GhostDoc plugin setting file + +# Node.js Tools for Visual Studio + +# Visual Studio 6 build log + +# Visual Studio 6 workspace options file + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) + +# Visual Studio LightSwitch build output + +# Paket dependency manager + +# FAKE - F# Make + +# CodeRush personal settings + +# Python Tools for Visual Studio (PTVS) + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio + +# Telerik's JustMock configuration file + +# BizTalk build output + +# OpenCover UI analysis results + +# Azure Stream Analytics local run output + +# MSBuild Binary and Structured Log + +# NVidia Nsight GPU debugger configuration file + +# MFractors (Xamarin productivity tool) working folder + +# Local History for Visual Studio + +# BeatPulse healthcheck temp database + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 + +# Ionide (cross platform F# VS Code tools) working folder + +# End of https://www.toptal.com/developers/gitignore/api/visualstudio,dotnetcore,csharp diff --git a/DesktopNotifications.Apple/AppleNotificationManager.cs b/DesktopNotifications.Apple/AppleNotificationManager.cs new file mode 100644 index 0000000..76c2253 --- /dev/null +++ b/DesktopNotifications.Apple/AppleNotificationManager.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace DesktopNotifications.Apple +{ + public class AppleNotificationManager : INotificationManager + { + public void Dispose() + { + } + + public event EventHandler NotificationActivated; + public event EventHandler NotificationDismissed; + + public ValueTask Initialize() + { + } + + public ValueTask ShowNotification(Notification notification, DateTimeOffset? expirationTime = null) + { + } + } +} diff --git a/DesktopNotifications.Apple/DesktopNotifications.Apple.csproj b/DesktopNotifications.Apple/DesktopNotifications.Apple.csproj new file mode 100644 index 0000000..a2bf4d0 --- /dev/null +++ b/DesktopNotifications.Apple/DesktopNotifications.Apple.csproj @@ -0,0 +1,11 @@ + + + + netcoreapp3.1 + + + + + + + diff --git a/DesktopNotifications.Apple/Native.mm b/DesktopNotifications.Apple/Native.mm new file mode 100644 index 0000000..3c40fb9 --- /dev/null +++ b/DesktopNotifications.Apple/Native.mm @@ -0,0 +1,7 @@ + + +void __declspec(dllexport) ShowNotification(char* title, char* body) { + + + +} \ No newline at end of file diff --git a/DesktopNotifications.FreeDesktop/DesktopNotifications.FreeDesktop.csproj b/DesktopNotifications.FreeDesktop/DesktopNotifications.FreeDesktop.csproj new file mode 100644 index 0000000..233e5aa --- /dev/null +++ b/DesktopNotifications.FreeDesktop/DesktopNotifications.FreeDesktop.csproj @@ -0,0 +1,16 @@ + + + + netcoreapp3.1 + enable + + + + + + + + + + + diff --git a/DesktopNotifications.FreeDesktop/FreeDesktopNotificationManager.cs b/DesktopNotifications.FreeDesktop/FreeDesktopNotificationManager.cs new file mode 100644 index 0000000..0792675 --- /dev/null +++ b/DesktopNotifications.FreeDesktop/FreeDesktopNotificationManager.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Tmds.DBus; + +namespace DesktopNotifications.FreeDesktop +{ + public class FreeDesktopNotificationManager : INotificationManager, IDisposable + { + private const string NotificationsService = "org.freedesktop.Notifications"; + + private static readonly ObjectPath NotificationsPath = new ObjectPath("/org/freedesktop/Notifications"); + private Connection? _connection; + private IDisposable? _notificationActionSubscription; + private IDisposable? _notificationCloseSubscription; + private Dictionary _activeNotifications; + + private IFreeDesktopNotificationsProxy? _proxy; + + public FreeDesktopNotificationManager() + { + _activeNotifications = new Dictionary(); + } + + public void Dispose() + { + _notificationActionSubscription?.Dispose(); + _notificationCloseSubscription?.Dispose(); + } + + public event EventHandler? NotificationActivated; + public event EventHandler? NotificationDismissed; + + public async ValueTask Initialize() + { + _connection = Connection.Session; + + await _connection.ConnectAsync(); + + _proxy = _connection.CreateProxy( + NotificationsService, + NotificationsPath + ); + + _notificationActionSubscription = await _proxy.WatchActionInvokedAsync( + OnNotificationActionInvoked, + OnNotificationActionInvokedError + ); + _notificationCloseSubscription = await _proxy.WatchNotificationClosedAsync( + OnNotificationClosed, + OnNotificationClosedError + ); + } + + public async ValueTask ShowNotification(Notification notification, DateTimeOffset? expirationTime = null) + { + if (_connection == null || _proxy == null) + { + throw new InvalidOperationException("Not connected. Call Initialize() first."); + } + + var duration = expirationTime - DateTimeOffset.Now; + var actions = GenerateActions(notification); + + var id = await _proxy.NotifyAsync( + "MyApp", + 0, + string.Empty, + notification.Title, + notification.Body, + actions.ToArray(), + new Dictionary {{"urgency", 1}}, + duration?.Milliseconds ?? 0 + ).ConfigureAwait(false); + + _activeNotifications[id] = notification; + } + + private static IEnumerable GenerateActions(Notification notification) + { + foreach (var (title, actionId) in notification.Buttons) + { + yield return actionId; + yield return title; + } + } + + private void OnNotificationClosedError(Exception obj) + { + throw obj; + } + + private static NotificationDismissReason GetReason(uint reason) + { + return reason switch + { + 1 => NotificationDismissReason.Expired, + 2 => NotificationDismissReason.User, + 3 => NotificationDismissReason.Application, + _ => throw new ArgumentOutOfRangeException() + }; + } + + private void OnNotificationClosed((uint id, uint reason) @event) + { + _activeNotifications.Remove(@event.id, out var notification); + Debug.Assert(notification != null); + + var dismissReason = GetReason(@event.reason); + + NotificationDismissed?.Invoke(this, + new NotificationDismissedEventArgs(notification, dismissReason)); + } + + private void OnNotificationActionInvokedError(Exception obj) + { + throw obj; + } + + private void OnNotificationActionInvoked((uint id, string actionKey) @event) + { + var notification = _activeNotifications[@event.id]; + + NotificationActivated?.Invoke(this, + new NotificationActivatedEventArgs(notification, @event.actionKey)); + } + } +} \ No newline at end of file diff --git a/DesktopNotifications.FreeDesktop/FreeDesktopNotificationProxy.cs b/DesktopNotifications.FreeDesktop/FreeDesktopNotificationProxy.cs new file mode 100644 index 0000000..8f2c5ac --- /dev/null +++ b/DesktopNotifications.FreeDesktop/FreeDesktopNotificationProxy.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Tmds.DBus; + +[assembly: InternalsVisibleTo(Connection.DynamicAssemblyName)] + +namespace DesktopNotifications.FreeDesktop +{ + /// http://www.galago-project.org/specs/notification/0.9/x408.html + /// + /// Interface for notifications + /// + [DBusInterface("org.freedesktop.Notifications")] + internal interface IFreeDesktopNotificationsProxy : IDBusObject + { + Task NotifyAsync(string appName, uint replacesId, string appIcon, string summary, string body, string[] actions, IDictionary hints, int expireTimeout); + + Task CloseNotificationAsync(uint id); + + Task GetCapabilitiesAsync(); + + Task<(string name, string vendor, string version, string spec_version)> GetServerInformationAsync(); + + Task WatchNotificationClosedAsync(Action<(uint id, uint reason)> handler, Action onError = null); + + Task WatchActionInvokedAsync(Action<(uint id, string action_key)> handler, Action onError = null); + } + +} \ No newline at end of file diff --git a/DesktopNotifications.Windows/DesktopNotifications.Windows.csproj b/DesktopNotifications.Windows/DesktopNotifications.Windows.csproj new file mode 100644 index 0000000..54fac11 --- /dev/null +++ b/DesktopNotifications.Windows/DesktopNotifications.Windows.csproj @@ -0,0 +1,13 @@ + + + + netcoreapp3.1 + enable + + + + + + + + diff --git a/DesktopNotifications.Windows/WindowsNotificationManager.cs b/DesktopNotifications.Windows/WindowsNotificationManager.cs new file mode 100644 index 0000000..8d7d377 --- /dev/null +++ b/DesktopNotifications.Windows/WindowsNotificationManager.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Windows.Data.Xml.Dom; +using Windows.UI.Notifications; +using Microsoft.Toolkit.Uwp.Notifications; + +namespace DesktopNotifications.Windows +{ + public class WindowsNotificationManager : INotificationManager + { + private readonly Dictionary _notifications; + private readonly ToastNotifier _toastNotifier; + private TaskCompletionSource? _launchActionPromise; + + /// + /// + /// + public WindowsNotificationManager(string appId) + { + _toastNotifier = ToastNotificationManager.CreateToastNotifier(appId); + _notifications = new Dictionary(); + } + + public event EventHandler? NotificationActivated; + public event EventHandler? NotificationDismissed; + + public async ValueTask Initialize() + { + if (ToastNotificationManagerCompat.WasCurrentProcessToastActivated()) + { + _launchActionPromise = new TaskCompletionSource(); + ToastNotificationManagerCompat.OnActivated += OnAppActivated; + + var launchAction = await _launchActionPromise.Task; + + Debug.Assert(launchAction != null); + + //TODO: Lookup notification object from history? + NotificationActivated?.Invoke(this, + new NotificationActivatedEventArgs(null, launchAction)); + } + } + + private static XmlDocument GenerateXml(Notification notification) + { + var builder = new ToastContentBuilder(); + + builder.AddText(notification.Title); + builder.AddText(notification.Body); + + foreach (var (title, actionId) in notification.Buttons) + { + builder.AddButton(title, ToastActivationType.Foreground, actionId); + } + + return builder.GetXml(); + } + + public ValueTask ShowNotification(Notification notification, DateTimeOffset? expirationTime) + { + if (expirationTime < DateTimeOffset.Now) + { + throw new ArgumentException(nameof(expirationTime)); + } + + var xmlContent = GenerateXml(notification); + var toastNotification = new ToastNotification(xmlContent) + { + ExpirationTime = expirationTime + }; + + toastNotification.Activated += ToastNotificationOnActivated; + toastNotification.Dismissed += ToastNotificationOnDismissed; + toastNotification.Failed += ToastNotificationOnFailed; + + _toastNotifier.Show(toastNotification); + _notifications[toastNotification] = notification; + + return default; + } + + private void OnAppActivated(ToastNotificationActivatedEventArgsCompat e) + { + Debug.Assert(_launchActionPromise != null); + _launchActionPromise.SetResult(e.Argument); + } + + private static void ToastNotificationOnFailed(ToastNotification sender, ToastFailedEventArgs args) + { + throw args.ErrorCode; + } + + private void ToastNotificationOnDismissed(ToastNotification sender, ToastDismissedEventArgs args) + { + var notification = _notifications[sender]; + var reason = args.Reason switch + { + ToastDismissalReason.UserCanceled => NotificationDismissReason.User, + ToastDismissalReason.TimedOut => NotificationDismissReason.Expired, + ToastDismissalReason.ApplicationHidden => NotificationDismissReason.Application, + _ => throw new ArgumentOutOfRangeException() + }; + + NotificationDismissed?.Invoke(this, new NotificationDismissedEventArgs(notification, reason)); + + _notifications.Remove(sender); + } + + private void ToastNotificationOnActivated(ToastNotification sender, object args) + { + var activationArgs = (ToastActivatedEventArgs) args; + var notification = _notifications[sender]; + var actionId = string.IsNullOrEmpty(activationArgs.Arguments) ? "default" : activationArgs.Arguments; + + NotificationActivated?.Invoke(this, new NotificationActivatedEventArgs(notification, actionId)); + } + + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/DesktopNotifications.sln b/DesktopNotifications.sln new file mode 100644 index 0000000..3a20aff --- /dev/null +++ b/DesktopNotifications.sln @@ -0,0 +1,49 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DesktopNotifications", "DesktopNotifications\DesktopNotifications.csproj", "{64E5A8ED-29CB-4114-B1B8-EC14512EB15E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DesktopNotifications.Windows", "DesktopNotifications.Windows\DesktopNotifications.Windows.csproj", "{8BD34395-0FB7-43D1-8127-94B525AF2392}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example", "Example\Example.csproj", "{CEB162D5-CC75-4FC9-9A8B-E3C58E7155F0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DesktopNotifications.FreeDesktop", "DesktopNotifications.FreeDesktop\DesktopNotifications.FreeDesktop.csproj", "{96F637EF-FD11-41F5-8F40-4E37313697E3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DesktopNotifications.Apple", "DesktopNotifications.Apple\DesktopNotifications.Apple.csproj", "{B4A6C639-79C4-48EF-955F-273CF01D7770}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {64E5A8ED-29CB-4114-B1B8-EC14512EB15E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64E5A8ED-29CB-4114-B1B8-EC14512EB15E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64E5A8ED-29CB-4114-B1B8-EC14512EB15E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64E5A8ED-29CB-4114-B1B8-EC14512EB15E}.Release|Any CPU.Build.0 = Release|Any CPU + {8BD34395-0FB7-43D1-8127-94B525AF2392}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BD34395-0FB7-43D1-8127-94B525AF2392}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BD34395-0FB7-43D1-8127-94B525AF2392}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BD34395-0FB7-43D1-8127-94B525AF2392}.Release|Any CPU.Build.0 = Release|Any CPU + {CEB162D5-CC75-4FC9-9A8B-E3C58E7155F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEB162D5-CC75-4FC9-9A8B-E3C58E7155F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEB162D5-CC75-4FC9-9A8B-E3C58E7155F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEB162D5-CC75-4FC9-9A8B-E3C58E7155F0}.Release|Any CPU.Build.0 = Release|Any CPU + {96F637EF-FD11-41F5-8F40-4E37313697E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96F637EF-FD11-41F5-8F40-4E37313697E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96F637EF-FD11-41F5-8F40-4E37313697E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96F637EF-FD11-41F5-8F40-4E37313697E3}.Release|Any CPU.Build.0 = Release|Any CPU + {B4A6C639-79C4-48EF-955F-273CF01D7770}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4A6C639-79C4-48EF-955F-273CF01D7770}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4A6C639-79C4-48EF-955F-273CF01D7770}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4A6C639-79C4-48EF-955F-273CF01D7770}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {95891D52-BFE3-4A10-9823-99F37EAA6058} + EndGlobalSection +EndGlobal diff --git a/DesktopNotifications/DesktopNotifications.csproj b/DesktopNotifications/DesktopNotifications.csproj new file mode 100644 index 0000000..80fe945 --- /dev/null +++ b/DesktopNotifications/DesktopNotifications.csproj @@ -0,0 +1,8 @@ + + + + netcoreapp3.1 + enable + + + diff --git a/DesktopNotifications/INotificationManager.cs b/DesktopNotifications/INotificationManager.cs new file mode 100644 index 0000000..40f7ae2 --- /dev/null +++ b/DesktopNotifications/INotificationManager.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; + +namespace DesktopNotifications +{ + /// + /// Interface for notification managers that handle the presentation and lifetime of notifications. + /// + public interface INotificationManager : IDisposable + { + /// + /// Raised when a notification was activated. The notion of "activation" varies from platform to platform. + /// + event EventHandler NotificationActivated; + + /// + /// Raised when a notification was dismissed. The exact reason can be found in . + /// + event EventHandler NotificationDismissed; + + /// + /// + /// + /// + ValueTask Initialize(); + + /// + /// Schedules a notification for presentation. + /// + /// The notification to present. + /// The expiration time marking the point when the notification gets removed. + ValueTask ShowNotification(Notification notification, DateTimeOffset? expirationTime = null); + } +} \ No newline at end of file diff --git a/DesktopNotifications/Notification.cs b/DesktopNotifications/Notification.cs new file mode 100644 index 0000000..ab41fdd --- /dev/null +++ b/DesktopNotifications/Notification.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace DesktopNotifications +{ + /// + /// + public class Notification + { + public Notification() + { + Buttons = new List<(string Title, string ActionId)>(); + } + + public string Title { get; set; } + + public string Body { get; set; } + + public List<(string Title, string ActionId)> Buttons { get; } + } +} \ No newline at end of file diff --git a/DesktopNotifications/NotificationActivatedEventArgs.cs b/DesktopNotifications/NotificationActivatedEventArgs.cs new file mode 100644 index 0000000..b129995 --- /dev/null +++ b/DesktopNotifications/NotificationActivatedEventArgs.cs @@ -0,0 +1,20 @@ +namespace DesktopNotifications +{ + /// + /// + /// + public class NotificationActivatedEventArgs : NotificationEventArgs + { + public NotificationActivatedEventArgs(Notification notification, string actionId) + : base(notification) + { + ActionId = actionId; + } + + /// + /// The id associated with the activation action. "default" denotes the platform-specific default action. + /// On Windows this means the user clicked on the notification. + /// + public string ActionId { get; } + } +} \ No newline at end of file diff --git a/DesktopNotifications/NotificationDismissReason.cs b/DesktopNotifications/NotificationDismissReason.cs new file mode 100644 index 0000000..8c521a2 --- /dev/null +++ b/DesktopNotifications/NotificationDismissReason.cs @@ -0,0 +1,23 @@ +namespace DesktopNotifications +{ + /// + /// Reasons why a notification was dismissed. + /// + public enum NotificationDismissReason + { + /// + /// The user closed the notification. + /// + User, + + /// + /// The notification expired. + /// + Expired, + + /// + /// The notification was explicitly removed by application code. + /// + Application + } +} \ No newline at end of file diff --git a/DesktopNotifications/NotificationDismissedEventArgs.cs b/DesktopNotifications/NotificationDismissedEventArgs.cs new file mode 100644 index 0000000..732a06d --- /dev/null +++ b/DesktopNotifications/NotificationDismissedEventArgs.cs @@ -0,0 +1,16 @@ +namespace DesktopNotifications +{ + /// + /// + public class NotificationDismissedEventArgs + { + public NotificationDismissedEventArgs(Notification notification, NotificationDismissReason reason) + { + Notification = notification; + Reason = reason; + } + + public Notification Notification { get; } + public NotificationDismissReason Reason { get; } + } +} \ No newline at end of file diff --git a/DesktopNotifications/NotificationEventArgs.cs b/DesktopNotifications/NotificationEventArgs.cs new file mode 100644 index 0000000..f037370 --- /dev/null +++ b/DesktopNotifications/NotificationEventArgs.cs @@ -0,0 +1,17 @@ +namespace DesktopNotifications +{ + /// + /// + public class NotificationEventArgs + { + public NotificationEventArgs(Notification notification) + { + Notification = notification; + } + + /// + /// + /// + public Notification Notification { get; } + } +} \ No newline at end of file diff --git a/Example/Example.csproj b/Example/Example.csproj new file mode 100644 index 0000000..7ecc35e --- /dev/null +++ b/Example/Example.csproj @@ -0,0 +1,15 @@ + + + + Exe + netcoreapp3.1 + enable + + + + + + + + + diff --git a/Example/Program.cs b/Example/Program.cs new file mode 100644 index 0000000..89f9dbe --- /dev/null +++ b/Example/Program.cs @@ -0,0 +1,87 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using DesktopNotifications; +using DesktopNotifications.FreeDesktop; +using DesktopNotifications.Windows; +using Example.Win32; + +namespace Example +{ + internal class Program + { + [DllImport("shell32.dll", SetLastError = true)] + internal static extern void SetCurrentProcessExplicitAppUserModelID( + [MarshalAs(UnmanagedType.LPWStr)] string appId); + + private static INotificationManager CreateManager() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return new FreeDesktopNotificationManager(); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + const string AppName = "DesktopNotificationsExample"; + + SetCurrentProcessExplicitAppUserModelID(AppName); + + using var shortcut = new ShellLink + { + TargetPath = Process.GetCurrentProcess().MainModule.FileName, + Arguments = string.Empty, + AppUserModelID = AppName + }; + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var startMenuPath = Path.Combine(appData, @"Microsoft\Windows\Start Menu\Programs"); + var shortcutFile = Path.Combine(startMenuPath, $"{AppName}.lnk"); + + shortcut.Save(shortcutFile); + + return new WindowsNotificationManager(AppName); + } + + throw new PlatformNotSupportedException(); + } + + private static async Task Main(string[] args) + { + using var manager = CreateManager(); + + manager.NotificationActivated += ManagerOnNotificationActivated; + manager.NotificationDismissed += ManagerOnNotificationDismissed; + + await manager.Initialize(); + + var notification = new Notification + { + Title = "Hello World!", + Body = "Isn't this awesome?", + Buttons = + { + ("Yes", "answer_yes"), + ("No", "answer_no"), + ("Maybe", "answer_maybe") + } + }; + + await manager.ShowNotification(notification); + + await Task.Delay(10_000); + } + + private static void ManagerOnNotificationDismissed(object? sender, NotificationDismissedEventArgs e) + { + Console.WriteLine($"Notification dismissed: {e.Reason}"); + } + + private static void ManagerOnNotificationActivated(object? sender, NotificationActivatedEventArgs e) + { + Console.WriteLine($"Notification activated: {e.ActionId}"); + } + } +} \ No newline at end of file diff --git a/Example/ShellLink.cs b/Example/ShellLink.cs new file mode 100644 index 0000000..8ec1289 --- /dev/null +++ b/Example/ShellLink.cs @@ -0,0 +1,449 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; + +namespace Example.Win32 +{ + // Modified from http://smdn.jp/programming/tips/createlnk/ + // Originally from http://www.vbaccelerator.com/home/NET/Code/Libraries/Shell_Projects + // /Creating_and_Modifying_Shortcuts/article.asp + // Partly based on Sending toast notifications from desktop apps sample + public class ShellLink : IDisposable + { + #region Win32 and COM + + // IShellLink Interface + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214F9-0000-0000-C000-000000000046")] + private interface IShellLinkW + { + uint GetPath([Out] [MarshalAs(UnmanagedType.LPWStr)] + StringBuilder pszFile, + int cchMaxPath, ref WIN32_FIND_DATAW pfd, uint fFlags); + + uint GetIDList(out IntPtr ppidl); + uint SetIDList(IntPtr pidl); + + uint GetDescription([Out] [MarshalAs(UnmanagedType.LPWStr)] + StringBuilder pszName, + int cchMaxName); + + uint SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + + uint GetWorkingDirectory([Out] [MarshalAs(UnmanagedType.LPWStr)] + StringBuilder pszDir, + int cchMaxPath); + + uint SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + + uint GetArguments([Out] [MarshalAs(UnmanagedType.LPWStr)] + StringBuilder pszArgs, + int cchMaxPath); + + uint SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + uint GetHotKey(out ushort pwHotkey); + uint SetHotKey(ushort wHotKey); + uint GetShowCmd(out int piShowCmd); + uint SetShowCmd(int iShowCmd); + + uint GetIconLocation([Out] [MarshalAs(UnmanagedType.LPWStr)] + StringBuilder pszIconPath, + int cchIconPath, out int piIcon); + + uint SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + + uint SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, + uint dwReserved); + + uint Resolve(IntPtr hwnd, uint fFlags); + uint SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); + } + + // ShellLink CoClass (ShellLink object) + [ComImport] + [ClassInterface(ClassInterfaceType.None)] + [Guid("00021401-0000-0000-C000-000000000046")] + private class CShellLink + { + } + + // WIN32_FIND_DATAW Structure + [StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Unicode)] + private struct WIN32_FIND_DATAW + { + public readonly uint dwFileAttributes; + public readonly FILETIME ftCreationTime; + public readonly FILETIME ftLastAccessTime; + public readonly FILETIME ftLastWriteTime; + public readonly uint nFileSizeHigh; + public readonly uint nFileSizeLow; + public readonly uint dwReserved0; + public readonly uint dwReserved1; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = MAX_PATH)] + public readonly string cFileName; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] + public readonly string cAlternateFileName; + } + + // IPropertyStore Interface + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] + private interface IPropertyStore + { + uint GetCount([Out] out uint cProps); + uint GetAt([In] uint iProp, out PropertyKey pkey); + uint GetValue([In] ref PropertyKey key, [Out] PropVariant pv); + uint SetValue([In] ref PropertyKey key, [In] PropVariant pv); + uint Commit(); + } + + // PropertyKey Structure + // Narrowed down from PropertyKey.cs of Windows API Code Pack 1.1 + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct PropertyKey + { + #region Fields + + #endregion + + #region Public Properties + + public Guid FormatId { get; } + + public int PropertyId { get; } + + #endregion + + #region Constructor + + public PropertyKey(Guid formatId, int propertyId) + { + FormatId = formatId; + PropertyId = propertyId; + } + + public PropertyKey(string formatId, int propertyId) + { + FormatId = new Guid(formatId); + PropertyId = propertyId; + } + + #endregion + } + + // PropVariant Class (only for string value) + // Narrowed down from PropVariant.cs of Windows API Code Pack 1.1 + // Originally from http://blogs.msdn.com/b/adamroot/archive/2008/04/11 + // /interop-with-propvariants-in-net.aspx + [StructLayout(LayoutKind.Explicit)] + private sealed class PropVariant : IDisposable + { + #region Fields + + [FieldOffset(0)] private ushort valueType; // Value type + + // [FieldOffset(2)] + // ushort wReserved1; // Reserved field + // [FieldOffset(4)] + // ushort wReserved2; // Reserved field + // [FieldOffset(6)] + // ushort wReserved3; // Reserved field + + [FieldOffset(8)] private readonly IntPtr ptr; // Value + + #endregion + + #region Public Properties + + // Value type (System.Runtime.InteropServices.VarEnum) + public VarEnum VarType + { + get => (VarEnum) valueType; + set => valueType = (ushort) value; + } + + // Whether value is empty or null + public bool IsNullOrEmpty => + valueType == (ushort) VarEnum.VT_EMPTY || + valueType == (ushort) VarEnum.VT_NULL; + + // Value (only for string value) + public string? Value => Marshal.PtrToStringUni(ptr); + + #endregion + + #region Constructor + + public PropVariant() + { + } + + // Construct with string value + public PropVariant(string value) + { + if (value == null) + { + throw new ArgumentException("Failed to set value."); + } + + valueType = (ushort) VarEnum.VT_LPWSTR; + ptr = Marshal.StringToCoTaskMemUni(value); + } + + #endregion + + #region Destructor + + ~PropVariant() + { + Dispose(); + } + + public void Dispose() + { + PropVariantClear(this); + GC.SuppressFinalize(this); + } + + #endregion + } + + [DllImport("Ole32.dll", PreserveSig = false)] + private static extern void PropVariantClear([In] [Out] PropVariant pvar); + + #endregion + + #region Fields + + private IShellLinkW? shellLinkW; + + // Name = System.AppUserModel.ID + // ShellPKey = PKEY_AppUserModel_ID + // FormatID = 9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3 + // PropID = 5 + // Type = String (VT_LPWSTR) + private readonly PropertyKey AppUserModelIDKey = + new PropertyKey("{9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}", 5); + + private const int MAX_PATH = 260; + private const int INFOTIPSIZE = 1024; + + private const int STGM_READ = 0x00000000; // STGM constants + private const uint SLGP_UNCPRIORITY = 0x0002; // SLGP flags + + #endregion + + #region Private Properties (Interfaces) + + private IPersistFile PersistFile + { + get + { + if (!(shellLinkW is IPersistFile persistFile)) + { + throw new COMException("Failed to create IPersistFile."); + } + + return persistFile; + } + } + + private IPropertyStore PropertyStore + { + get + { + if (!(shellLinkW is IPropertyStore PropertyStore)) + { + throw new COMException("Failed to create IPropertyStore."); + } + + return PropertyStore; + } + } + + #endregion + + #region Public Properties (Minimal) + + // Path of loaded shortcut file + public string ShortcutFile + { + get + { + string shortcutFile; + + PersistFile.GetCurFile(out shortcutFile); + + return shortcutFile; + } + } + + // Path of target file + public string TargetPath + { + get + { + // No limitation to length of buffer string in the case of Unicode though. + StringBuilder targetPath = new StringBuilder(MAX_PATH); + + var data = new WIN32_FIND_DATAW(); + + VerifySucceeded(shellLinkW!.GetPath(targetPath, targetPath.Capacity, ref data, + SLGP_UNCPRIORITY)); + + return targetPath.ToString(); + } + set => VerifySucceeded(shellLinkW!.SetPath(value)); + } + + public string Arguments + { + get + { + // No limitation to length of buffer string in the case of Unicode though. + StringBuilder arguments = new StringBuilder(INFOTIPSIZE); + + VerifySucceeded(shellLinkW!.GetArguments(arguments, arguments.Capacity)); + + return arguments.ToString(); + } + set => VerifySucceeded(shellLinkW!.SetArguments(value)); + } + + // AppUserModelID to be used for Windows 7 or later. + public string AppUserModelID + { + get + { + using (PropVariant pv = new PropVariant()) + { + VerifySucceeded(PropertyStore.GetValue(AppUserModelIDKey, pv)); + + if (pv.Value == null) + { + return "Null"; + } + + return pv.Value; + } + } + set + { + using (PropVariant pv = new PropVariant(value)) + { + VerifySucceeded(PropertyStore.SetValue(AppUserModelIDKey, pv)); + VerifySucceeded(PropertyStore.Commit()); + } + } + } + + #endregion + + #region Constructor + + public ShellLink() + : this(null) + { + } + + // Construct with loading shortcut file. + public ShellLink(string? file) + { + try + { + shellLinkW = (IShellLinkW) new CShellLink(); + } + catch + { + throw new COMException("Failed to create ShellLink object."); + } + + if (file != null) + { + Load(file); + } + } + + #endregion + + #region Destructor + + ~ShellLink() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (shellLinkW != null) + { + // Release all references. + Marshal.FinalReleaseComObject(shellLinkW); + shellLinkW = null; + } + } + + #endregion + + #region Methods + + // Save shortcut file. + public void Save() + { + string file = ShortcutFile; + + if (file == null) + { + throw new InvalidOperationException("File name is not given."); + } + + Save(file); + } + + public void Save(string file) + { + if (file == null) + { + throw new ArgumentNullException(nameof(file)); + } + + PersistFile.Save(file, true); + } + + // Load shortcut file. + public void Load(string file) + { + if (!File.Exists(file)) + { + throw new FileNotFoundException("File is not found.", file); + } + + PersistFile.Load(file, STGM_READ); + } + + // Verify if operation succeeded. + public static void VerifySucceeded(uint hresult) + { + if (hresult > 1) + { + throw new InvalidOperationException("Failed with HRESULT: " + + hresult.ToString("X")); + } + } + + #endregion + } +} \ No newline at end of file