From a29f6faa27cd6230ab47dd17207cc84b3404ea4e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 11 Mar 2020 15:22:17 +0100 Subject: [PATCH] Working on improving the toast notifications [skip ci] --- Greenshot/greenshot.manifest | 74 ++-- GreenshotPlugin/Core/WindowsVersion.cs | 3 + .../DesktopNotificationManagerCompat.cs | 384 ++++++++++++++++++ .../Native/GreenshotNotificationActivator.cs | 41 ++ .../ToastNotificationService.cs | 32 +- 5 files changed, 496 insertions(+), 38 deletions(-) create mode 100644 GreenshotWin10Plugin/Native/DesktopNotificationManagerCompat.cs create mode 100644 GreenshotWin10Plugin/Native/GreenshotNotificationActivator.cs diff --git a/Greenshot/greenshot.manifest b/Greenshot/greenshot.manifest index 919fcb895..cae29e2dc 100644 --- a/Greenshot/greenshot.manifest +++ b/Greenshot/greenshot.manifest @@ -1,44 +1,48 @@  - - - - - True/PM - - + + + + + True/PM + + true + true + PerMonitorV2,PerMonitor + true + true + + - + + - + - + - - - + - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/GreenshotPlugin/Core/WindowsVersion.cs b/GreenshotPlugin/Core/WindowsVersion.cs index ae9600f25..6dd4faa08 100644 --- a/GreenshotPlugin/Core/WindowsVersion.cs +++ b/GreenshotPlugin/Core/WindowsVersion.cs @@ -15,6 +15,7 @@ namespace GreenshotPlugin.Core /// public static Version WinVersion { get; } = Environment.OSVersion.Version; + public static double WinVersionTotal = WinVersion.Major + (double)WinVersion.Minor / 10; /// /// Test if the current OS is Windows 10 /// @@ -39,6 +40,8 @@ namespace GreenshotPlugin.Core /// true if we are running on Windows 7 or later public static bool IsWindows7OrLater { get; } = WinVersion.Major == 6 && WinVersion.Minor >= 1 || WinVersion.Major > 6; + public static bool IsWindows7OrLower { get; } = WinVersionTotal <= 6.1; + /// /// Test if the current OS is Windows 8.0 /// diff --git a/GreenshotWin10Plugin/Native/DesktopNotificationManagerCompat.cs b/GreenshotWin10Plugin/Native/DesktopNotificationManagerCompat.cs new file mode 100644 index 000000000..703768582 --- /dev/null +++ b/GreenshotWin10Plugin/Native/DesktopNotificationManagerCompat.cs @@ -0,0 +1,384 @@ +// ****************************************************************** +// Copyright (c) Microsoft. All rights reserved. +// This code is licensed under the MIT License (MIT). +// THE CODE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE CODE OR THE USE OR OTHER DEALINGS IN THE CODE. +// ****************************************************************** + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using Windows.UI.Notifications; +using GreenshotPlugin.Core; + +namespace GreenshotWin10Plugin.Native +{ + public class DesktopNotificationManagerCompat + { + public const string TOAST_ACTIVATED_LAUNCH_ARG = "-ToastActivated"; + + private static bool _registeredAumidAndComServer; + private static string _aumid; + private static bool _registeredActivator; + + /// + /// If not running under the Desktop Bridge, you must call this method to register your AUMID with the Compat library and to + /// register your COM CLSID and EXE in LocalServer32 registry. Feel free to call this regardless, and we will no-op if running + /// under Desktop Bridge. Call this upon application startup, before calling any other APIs. + /// + /// An AUMID that uniquely identifies your application. + public static void RegisterAumidAndComServer(string aumid) + where T : NotificationActivator + { + if (string.IsNullOrWhiteSpace(aumid)) + { + throw new ArgumentException("You must provide an AUMID.", nameof(aumid)); + } + + // If running as Desktop Bridge + if (DesktopBridgeHelpers.IsRunningAsUwp()) + { + // Clear the AUMID since Desktop Bridge doesn't use it, and then we're done. + // Desktop Bridge apps are registered with platform through their manifest. + // Their LocalServer32 key is also registered through their manifest. + _aumid = null; + _registeredAumidAndComServer = true; + return; + } + + _aumid = aumid; + + String exePath = Process.GetCurrentProcess().MainModule.FileName; + RegisterComServer(exePath); + + _registeredAumidAndComServer = true; + } + + private static void RegisterComServer(string exePath) + where T : NotificationActivator + { + // We register the EXE to start up when the notification is activated + string regString = $"SOFTWARE\\Classes\\CLSID\\{{{typeof(T).GUID}}}\\LocalServer32"; + var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(regString); + + // Include a flag so we know this was a toast activation and should wait for COM to process + // We also wrap EXE path in quotes for extra security + key.SetValue(null, '"' + exePath + '"' + " " + TOAST_ACTIVATED_LAUNCH_ARG); + } + + /// + /// Registers the activator type as a COM server client so that Windows can launch your activator. + /// + /// Your implementation of NotificationActivator. Must have GUID and ComVisible attributes on class. + public static void RegisterActivator() where T : NotificationActivator + { + // Register type + var regService = new RegistrationServices(); + + regService.RegisterTypeForComClients( + typeof(T), + RegistrationClassContext.LocalServer, + RegistrationConnectionType.MultipleUse); + + _registeredActivator = true; + } + + /// + /// Creates a toast notifier. You must have called first (and also if you're a classic Win32 app), or this will throw an exception. + /// + /// + public static ToastNotifier CreateToastNotifier() + { + EnsureRegistered(); + + if (_aumid != null) + { + // Non-Desktop Bridge + return ToastNotificationManager.CreateToastNotifier(_aumid); + } + + // Desktop Bridge + return ToastNotificationManager.CreateToastNotifier(); + } + + /// + /// Gets the object. You must have called first (and also if you're a classic Win32 app), or this will throw an exception. + /// + public static DesktopNotificationHistoryCompat History + { + get + { + EnsureRegistered(); + + return new DesktopNotificationHistoryCompat(_aumid); + } + } + + private static void EnsureRegistered() + { + // If not registered AUMID yet + if (!_registeredAumidAndComServer) + { + // Check if Desktop Bridge + if (DesktopBridgeHelpers.IsRunningAsUwp()) + { + // Implicitly registered, all good! + _registeredAumidAndComServer = true; + } + + else + { + // Otherwise, incorrect usage + throw new Exception("You must call RegisterAumidAndComServer first."); + } + } + + // If not registered activator yet + if (!_registeredActivator) + { + // Incorrect usage + throw new Exception("You must call RegisterActivator first."); + } + } + + /// + /// Gets a boolean representing whether http images can be used within toasts. This is true if running under Desktop Bridge. + /// + public static bool CanUseHttpImages { get { return DesktopBridgeHelpers.IsRunningAsUwp(); } } + + /// + /// Code from https://github.com/qmatteoq/DesktopBridgeHelpers/edit/master/DesktopBridge.Helpers/Helpers.cs + /// + private static class DesktopBridgeHelpers + { + const long APPMODEL_ERROR_NO_PACKAGE = 15700L; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern int GetCurrentPackageFullName(ref int packageFullNameLength, StringBuilder packageFullName); + + private static bool? _isRunningAsUwp; + public static bool IsRunningAsUwp() + { + if (_isRunningAsUwp != null) return _isRunningAsUwp.Value; + + if (WindowsVersion.IsWindows7OrLower) + { + _isRunningAsUwp = false; + } + else + { + int length = 0; + StringBuilder sb = new StringBuilder(0); + GetCurrentPackageFullName(ref length, sb); + + sb = new StringBuilder(length); + int result = GetCurrentPackageFullName(ref length, sb); + + _isRunningAsUwp = result != APPMODEL_ERROR_NO_PACKAGE; + } + + return _isRunningAsUwp.Value; + } + } + } + + /// + /// Manages the toast notifications for an app including the ability the clear all toast history and removing individual toasts. + /// + public sealed class DesktopNotificationHistoryCompat + { + private string _aumid; + private ToastNotificationHistory _history; + + /// + /// Do not call this. Instead, call to obtain an instance. + /// + /// + internal DesktopNotificationHistoryCompat(string aumid) + { + _aumid = aumid; + _history = ToastNotificationManager.History; + } + + /// + /// Removes all notifications sent by this app from action center. + /// + public void Clear() + { + if (_aumid != null) + { + _history.Clear(_aumid); + } + else + { + _history.Clear(); + } + } + + /// + /// Gets all notifications sent by this app that are currently still in Action Center. + /// + /// A collection of toasts. + public IReadOnlyList GetHistory() + { + return _aumid != null ? _history.GetHistory(_aumid) : _history.GetHistory(); + } + + /// + /// Removes an individual toast, with the specified tag label, from action center. + /// + /// The tag label of the toast notification to be removed. + public void Remove(string tag) + { + if (_aumid != null) + { + _history.Remove(tag, string.Empty, _aumid); + } + else + { + _history.Remove(tag); + } + } + + /// + /// Removes a toast notification from the action using the notification's tag and group labels. + /// + /// The tag label of the toast notification to be removed. + /// The group label of the toast notification to be removed. + public void Remove(string tag, string group) + { + if (_aumid != null) + { + _history.Remove(tag, group, _aumid); + } + else + { + _history.Remove(tag, group); + } + } + + /// + /// Removes a group of toast notifications, identified by the specified group label, from action center. + /// + /// The group label of the toast notifications to be removed. + public void RemoveGroup(string group) + { + if (_aumid != null) + { + _history.RemoveGroup(group, _aumid); + } + else + { + _history.RemoveGroup(group); + } + } + } + + /// + /// Apps must implement this activator to handle notification activation. + /// + public abstract class NotificationActivator : NotificationActivator.INotificationActivationCallback + { + public void Activate(string appUserModelId, string invokedArgs, NOTIFICATION_USER_INPUT_DATA[] data, uint dataCount) + { + OnActivated(invokedArgs, new NotificationUserInput(data), appUserModelId); + } + + /// + /// This method will be called when the user clicks on a foreground or background activation on a toast. Parent app must implement this method. + /// + /// The arguments from the original notification. This is either the launch argument if the user clicked the body of your toast, or the arguments from a button on your toast. + /// Text and selection values that the user entered in your toast. + /// Your AUMID. + public abstract void OnActivated(string arguments, NotificationUserInput userInput, string appUserModelId); + + // These are the new APIs for Windows 10 + #region NewAPIs + [StructLayout(LayoutKind.Sequential), Serializable] + public struct NOTIFICATION_USER_INPUT_DATA + { + [MarshalAs(UnmanagedType.LPWStr)] + public string Key; + + [MarshalAs(UnmanagedType.LPWStr)] + public string Value; + } + + [ComImport, + Guid("53E31837-6600-4A81-9395-75CFFE746F94"), ComVisible(true), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface INotificationActivationCallback + { + void Activate( + [In, MarshalAs(UnmanagedType.LPWStr)] + string appUserModelId, + [In, MarshalAs(UnmanagedType.LPWStr)] + string invokedArgs, + [In, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3)] + NOTIFICATION_USER_INPUT_DATA[] data, + [In, MarshalAs(UnmanagedType.U4)] + uint dataCount); + } + #endregion + } + + /// + /// Text and selection values that the user entered on your notification. The Key is the ID of the input, and the Value is what the user entered. + /// + public class NotificationUserInput : IReadOnlyDictionary + { + private NotificationActivator.NOTIFICATION_USER_INPUT_DATA[] _data; + + internal NotificationUserInput(NotificationActivator.NOTIFICATION_USER_INPUT_DATA[] data) + { + _data = data; + } + + public string this[string key] => _data.First(i => i.Key == key).Value; + + public IEnumerable Keys => _data.Select(i => i.Key); + + public IEnumerable Values => _data.Select(i => i.Value); + + public int Count => _data.Length; + + public bool ContainsKey(string key) + { + return _data.Any(i => i.Key == key); + } + + public IEnumerator> GetEnumerator() + { + return _data.Select(i => new KeyValuePair(i.Key, i.Value)).GetEnumerator(); + } + + public bool TryGetValue(string key, out string value) + { + foreach (var item in _data) + { + if (item.Key == key) + { + value = item.Value; + return true; + } + } + + value = null; + return false; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/GreenshotWin10Plugin/Native/GreenshotNotificationActivator.cs b/GreenshotWin10Plugin/Native/GreenshotNotificationActivator.cs new file mode 100644 index 000000000..74922476f --- /dev/null +++ b/GreenshotWin10Plugin/Native/GreenshotNotificationActivator.cs @@ -0,0 +1,41 @@ +/* + * Greenshot - a free and open source screenshot tool + * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom + * + * For more information see: http://getgreenshot.org/ + * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using System.Runtime.InteropServices; +using log4net; + +namespace GreenshotWin10Plugin.Native +{ + // The GUID CLSID must be unique to your app. Create a new GUID if copying this code. + [ClassInterface(ClassInterfaceType.None)] + [ComSourceInterfaces(typeof(INotificationActivationCallback))] + [Guid("F48E86D3-E34C-4DB7-8F8F-9A0EA55F0D08"), ComVisible(true)] + public class GreenshotNotificationActivator : NotificationActivator + { + private static readonly ILog Log = LogManager.GetLogger(typeof(GreenshotNotificationActivator)); + + public override void OnActivated(string invokedArgs, NotificationUserInput userInput, string appUserModelId) + { + // TODO: Handle activation + Log.Info("Activated"); + } + } +} diff --git a/GreenshotWin10Plugin/ToastNotificationService.cs b/GreenshotWin10Plugin/ToastNotificationService.cs index 1c3ad9dda..05d930bc8 100644 --- a/GreenshotWin10Plugin/ToastNotificationService.cs +++ b/GreenshotWin10Plugin/ToastNotificationService.cs @@ -27,6 +27,7 @@ using Windows.UI.Notifications; using GreenshotPlugin.Core; using GreenshotPlugin.IniFile; using GreenshotPlugin.Interfaces; +using GreenshotWin10Plugin.Native; using log4net; namespace GreenshotWin10Plugin @@ -42,6 +43,11 @@ namespace GreenshotWin10Plugin private readonly string _imageFilePath; public ToastNotificationService() { + // Register AUMID and COM server (for Desktop Bridge apps, this no-ops) + DesktopNotificationManagerCompat.RegisterAumidAndComServer("Greenshot.Greenshot"); + // Register COM server and activator type + DesktopNotificationManagerCompat.RegisterActivator(); + var localAppData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Greenshot"); if (!Directory.Exists(localAppData)) { @@ -58,6 +64,12 @@ namespace GreenshotWin10Plugin greenshotImage.Save(_imageFilePath, ImageFormat.Png); } + /// + /// This creates the actual toast + /// + /// string + /// Action called when clicked + /// Action called when the toast is closed private void ShowMessage(string message, Action onClickAction, Action onClosedAction) { // Do not inform the user if this is disabled @@ -65,6 +77,14 @@ namespace GreenshotWin10Plugin { return; } + // Prepare the toast notifier. Be sure to specify the AppUserModelId on your application's shortcut! + var toastNotifier = DesktopNotificationManagerCompat.CreateToastNotifier(); + if (toastNotifier.Setting != NotificationSetting.Enabled) + { + Log.DebugFormat("Ignored toast due to {0}", toastNotifier.Setting); + return; + } + // Get a toast XML template var toastXml = ToastNotificationManager.GetTemplateContent(ToastTemplateType.ToastImageAndText01); @@ -83,7 +103,6 @@ namespace GreenshotWin10Plugin } } - // Create the toast and attach event listeners var toast = new ToastNotification(toastXml); @@ -121,10 +140,17 @@ namespace GreenshotWin10Plugin toast.Dismissed -= ToastDismissedHandler; // Remove the other handler too toast.Activated -= ToastActivatedHandler; + toast.Failed -= ToastOnFailed; } toast.Dismissed += ToastDismissedHandler; - // Show the toast. Be sure to specify the AppUserModelId on your application's shortcut! - ToastNotificationManager.CreateToastNotifier(@"Greenshot").Show(toast); + toast.Failed += ToastOnFailed; + toastNotifier.Show(toast); + } + + private void ToastOnFailed(ToastNotification sender, ToastFailedEventArgs args) + { + Log.WarnFormat("Failed to display a toast due to {0}", args.ErrorCode); + Log.Debug(sender.Content.GetXml()); } public void ShowWarningMessage(string message, int timeout, Action onClickAction = null, Action onClosedAction = null)