From 6cf86d41f5e29e2a99b9d12f3e10ea4c8144dc1c Mon Sep 17 00:00:00 2001 From: Emby Toolbox Date: Sat, 16 May 2026 15:38:05 +0500 Subject: [PATCH] Register shortcut for Windows toast notifications --- EmbyToolbox/App.xaml.cs | 2 +- .../Interop/ToastShortcutRegistration.cs | 155 ++++++++++++++++++ EmbyToolbox/Services/NotificationService.cs | 7 + 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 EmbyToolbox/Interop/ToastShortcutRegistration.cs diff --git a/EmbyToolbox/App.xaml.cs b/EmbyToolbox/App.xaml.cs index 83a43ed..7d973a5 100644 --- a/EmbyToolbox/App.xaml.cs +++ b/EmbyToolbox/App.xaml.cs @@ -8,9 +8,9 @@ public partial class App : Application { protected override void OnStartup(StartupEventArgs e) { + _ = ToastShortcutRegistration.TryEnsureStartMenuShortcut(NotificationService.ToastAppUserModelId); _ = AppUserModelIdRegistration.TryRegister(NotificationService.ToastAppUserModelId); base.OnStartup(e); } } - diff --git a/EmbyToolbox/Interop/ToastShortcutRegistration.cs b/EmbyToolbox/Interop/ToastShortcutRegistration.cs new file mode 100644 index 0000000..ebe05db --- /dev/null +++ b/EmbyToolbox/Interop/ToastShortcutRegistration.cs @@ -0,0 +1,155 @@ +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; + +namespace EmbyToolbox.Interop; + +internal static class ToastShortcutRegistration +{ + private const int StgmReadwrite = 0x00000002; + + private static readonly PropertyKey AppUserModelIdKey = new( + new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), + 5); + + public static string? LastDiagnostics { get; private set; } + + public static bool TryEnsureStartMenuShortcut(string appUserModelId) + { + LastDiagnostics = null; + + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 10240)) + { + LastDiagnostics = "требуется Windows 10 (10240) или новее."; + return false; + } + + try + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath)) + { + LastDiagnostics = "не удалось определить путь к exe."; + return false; + } + + var programs = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu); + var shortcutPath = Path.Combine(programs, "Programs", "Emby Toolbox.lnk"); + Directory.CreateDirectory(Path.GetDirectoryName(shortcutPath)!); + + var shellLinkObject = (object)new CShellLink(); + var shellLink = (IShellLinkW)shellLinkObject; + shellLink.SetPath(exePath); + shellLink.SetArguments(string.Empty); + shellLink.SetWorkingDirectory(Path.GetDirectoryName(exePath) ?? AppContext.BaseDirectory); + shellLink.SetDescription("Emby Toolbox"); + + if (File.Exists(Path.Combine(AppContext.BaseDirectory, "Resources", "AppIcon.ico"))) + { + shellLink.SetIconLocation(Path.Combine(AppContext.BaseDirectory, "Resources", "AppIcon.ico"), 0); + } + else + { + shellLink.SetIconLocation(exePath, 0); + } + + using var appId = PropVariant.FromString(appUserModelId); + var propertyStore = (IPropertyStore)shellLink; + propertyStore.SetValue(AppUserModelIdKey, appId); + propertyStore.Commit(); + + var persistFile = (IPersistFile)shellLink; + persistFile.Save(shortcutPath, true); + return true; + } + catch (Exception ex) + { + LastDiagnostics = $"{ex.GetType().Name}: {ex.Message}"; + return false; + } + } + + [ComImport] + [Guid("00021401-0000-0000-C000-000000000046")] + private sealed class CShellLink + { + } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214F9-0000-0000-C000-000000000046")] + private interface IShellLinkW + { + void GetPath(IntPtr pszFile, int cchMaxPath, IntPtr pfd, uint fFlags); + void GetIDList(out IntPtr ppidl); + void SetIDList(IntPtr pidl); + void GetDescription(IntPtr pszName, int cchMaxName); + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetWorkingDirectory(IntPtr pszDir, int cchMaxPath); + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + void GetArguments(IntPtr pszArgs, int cchMaxPath); + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + void GetHotkey(out short pwHotkey); + void SetHotkey(short wHotkey); + void GetShowCmd(out int piShowCmd); + void SetShowCmd(int iShowCmd); + void GetIconLocation(IntPtr pszIconPath, int cchIconPath, out int piIcon); + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved); + void Resolve(IntPtr hwnd, uint fFlags); + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); + } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("00000138-0000-0000-C000-000000000046")] + private interface IPropertyStore + { + void GetCount(out uint cProps); + void GetAt(uint iProp, out PropertyKey pkey); + void GetValue(ref PropertyKey key, out PropVariant pv); + void SetValue(in PropertyKey key, in PropVariant pv); + void Commit(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private readonly struct PropertyKey(Guid formatId, int propertyId) + { + private readonly Guid _formatId = formatId; + private readonly int _propertyId = propertyId; + } + + [StructLayout(LayoutKind.Sequential)] + private sealed class PropVariant : IDisposable + { + private ushort _valueType; + private ushort _reserved1; + private ushort _reserved2; + private ushort _reserved3; + private IntPtr _value; + private IntPtr _reserved4; + + public static PropVariant FromString(string value) + { + return new PropVariant + { + _valueType = 31, // VT_LPWSTR + _value = Marshal.StringToCoTaskMemUni(value) + }; + } + + public void Dispose() + { + PropVariantClear(this); + GC.SuppressFinalize(this); + } + + ~PropVariant() + { + PropVariantClear(this); + } + + [DllImport("ole32.dll")] + private static extern int PropVariantClear([In, Out] PropVariant pvar); + } +} diff --git a/EmbyToolbox/Services/NotificationService.cs b/EmbyToolbox/Services/NotificationService.cs index 9dad4d5..8db34f5 100644 --- a/EmbyToolbox/Services/NotificationService.cs +++ b/EmbyToolbox/Services/NotificationService.cs @@ -35,6 +35,13 @@ public sealed class NotificationService private void LogAppUserModelRegistrationState() { + if (!string.IsNullOrWhiteSpace(ToastShortcutRegistration.LastDiagnostics)) + { + _logging.Warning( + $"Windows toast: ярлык Start Menu не подготовлен ({ToastShortcutRegistration.LastDiagnostics})", + "notify"); + } + if (string.Equals(AppUserModelIdRegistration.LastRegisteredId, ToastAppUserModelId, StringComparison.Ordinal)) { _logging.Info($"App User Model ID: {ToastAppUserModelId}", "notify");