From afde7398b4130427e2e431c67d23a2d9f0122f6b Mon Sep 17 00:00:00 2001 From: ema Date: Tue, 13 Oct 2026 19:36:59 +0800 Subject: [PATCH 1/3] Register an AppUserModelID so the media flyout identifies Upgrade WPFMediaKit to 3.0.1 and switch DirectShowLib to the NuGet package. --- .../QuickLook.Plugin.VideoViewer.csproj | 6 +- QuickLook/App.xaml.cs | 3 + QuickLook/NativeMethods/AppUserModel.cs | 298 ++++++++++++++++++ 3 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 QuickLook/NativeMethods/AppUserModel.cs diff --git a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj index defbc3462..2d56451b6 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj +++ b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj @@ -57,16 +57,14 @@ - + + - - False - diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs index b78e6d5ea..5bac92a49 100644 --- a/QuickLook/App.xaml.cs +++ b/QuickLook/App.xaml.cs @@ -60,6 +60,9 @@ static App() RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly; } + // SMTC + AppUserModel.Register("QuickLook", "QuickLook"); + #if DEBUG if (SHCore.GetProcessDpiAwareness(0, out SHCore.PROCESS_DPI_AWARENESS awareness) == 0) Debug.WriteLine($"Process DPI awareness: {awareness} ({(int)awareness})"); diff --git a/QuickLook/NativeMethods/AppUserModel.cs b/QuickLook/NativeMethods/AppUserModel.cs new file mode 100644 index 000000000..a823fe8b0 --- /dev/null +++ b/QuickLook/NativeMethods/AppUserModel.cs @@ -0,0 +1,298 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// 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 3 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; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; + +namespace QuickLook.NativeMethods; + +/// +/// Registers the process identity the Windows shell uses for taskbar grouping +/// and the system media flyout. Call before any window is created. +/// The directory where the lnk file is located %APPDATA%\Microsoft\Windows\Start Menu\Programs +/// +internal static class AppUserModel +{ + private static readonly PropertyKey AppUserModelIdKey = new() + { + FormatId = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), + PropertyId = 5 + }; + + /// + /// Assigns to this process and publishes + /// through a per-user Start menu shortcut. + /// The media flyout shows the shortcut name. + /// + /// + /// Process identity, such as QuickLook. + /// Shorter than 128 characters and without spaces. + /// + /// + /// Name shown for this app, such as QuickLook. + /// + public static void Register(string appUserModelId, string displayName) + { + if (string.IsNullOrWhiteSpace(appUserModelId) || appUserModelId.Length >= 128 || appUserModelId.IndexOf(' ') >= 0) + throw new ArgumentException("AppUserModelID must be shorter than 128 characters and cannot contain spaces.", nameof(appUserModelId)); + if (string.IsNullOrWhiteSpace(displayName) || displayName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new ArgumentException("Display name must be a valid file name.", nameof(displayName)); + + string shortcutPath = RegisterShortcut(appUserModelId, displayName, out bool shortcutExisted); + int hr = SetCurrentProcessExplicitAppUserModelID(appUserModelId); + if (hr < 0) + Marshal.ThrowExceptionForHR(hr); + + NotifyShell(shortcutPath, shortcutExisted ? SHCNE_UPDATEITEM : SHCNE_CREATE); + } + + /// + /// Deletes every shortcut under this user's Start menu Programs folder whose + /// System.AppUserModel.ID property equals . + /// writes that property, so this removes every shortcut it created, + /// including ones left under an older display name. + /// + public static void RemoveShortcuts(string appUserModelId) + { + if (string.IsNullOrWhiteSpace(appUserModelId) || appUserModelId.Length >= 128 || appUserModelId.IndexOf(' ') >= 0) + throw new ArgumentException("AppUserModelID must be shorter than 128 characters and cannot contain spaces.", nameof(appUserModelId)); + + string programs = Environment.GetFolderPath(Environment.SpecialFolder.Programs); + if (!Directory.Exists(programs)) + return; + + foreach (string shortcutPath in Directory.EnumerateFiles(programs, "*.lnk", SearchOption.AllDirectories)) + { + if (!ShortcutHasAppUserModelId(shortcutPath, appUserModelId)) + continue; + + File.Delete(shortcutPath); + NotifyShell(shortcutPath, SHCNE_DELETE); + } + } + + private static string RegisterShortcut(string appUserModelId, string displayName, out bool shortcutExisted) + { + ProcessModule module = Process.GetCurrentProcess().MainModule; + string exePath = module?.FileName; + if (string.IsNullOrEmpty(exePath)) + throw new InvalidOperationException("The current process has no executable path."); + + string shortcutPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Programs), + displayName + ".lnk"); + shortcutExisted = File.Exists(shortcutPath); + Directory.CreateDirectory(Path.GetDirectoryName(shortcutPath)); + + var shellLink = (IShellLinkW)new ShellLink(); + try + { + shellLink.SetPath(exePath); + shellLink.SetWorkingDirectory(Path.GetDirectoryName(exePath)); + shellLink.SetDescription(displayName); + shellLink.SetIconLocation(exePath, 0); + + var store = (IPropertyStore)shellLink; + var key = AppUserModelIdKey; + PropVariant value = PropVariant.FromString(appUserModelId); + try + { + store.SetValue(ref key, ref value); + store.Commit(); + } + finally + { + PropVariantClear(ref value); + } + + ((IPersistFile)shellLink).Save(shortcutPath, true); + } + finally + { + Marshal.FinalReleaseComObject(shellLink); + } + + return shortcutPath; + } + + private static bool ShortcutHasAppUserModelId(string shortcutPath, string appUserModelId) + { + var shellLink = (IShellLinkW)new ShellLink(); + try + { + ((IPersistFile)shellLink).Load(shortcutPath, StgmRead); + + var store = (IPropertyStore)shellLink; + var key = AppUserModelIdKey; + store.GetValue(ref key, out PropVariant value); + try + { + return string.Equals(value.ReadString(), appUserModelId, StringComparison.Ordinal); + } + finally + { + PropVariantClear(ref value); + } + } + catch (COMException) + { + return false; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + finally + { + Marshal.FinalReleaseComObject(shellLink); + } + } + + private static void NotifyShell(string shortcutPath, int eventId) + { + IntPtr path = Marshal.StringToCoTaskMemUni(shortcutPath); + try + { + SHChangeNotify(eventId, SHCNF_PATHW | SHCNF_FLUSHNOWAIT, path, IntPtr.Zero); + } + finally + { + Marshal.FreeCoTaskMem(path); + } + } + + private const int SHCNE_DELETE = 0x00000004; + private const int SHCNE_CREATE = 0x00000002; + private const int SHCNE_UPDATEITEM = 0x00002000; + private const int StgmRead = 0; + private const uint SHCNF_PATHW = 0x0005; + private const uint SHCNF_FLUSHNOWAIT = 0x2000; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SetCurrentProcessExplicitAppUserModelID(string appId); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern void SHChangeNotify(int eventId, uint flags, IntPtr item1, IntPtr item2); + + [DllImport("ole32.dll")] + private static extern int PropVariantClear(ref PropVariant variant); + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct PropertyKey + { + public Guid FormatId; + public uint PropertyId; + } + + [StructLayout(LayoutKind.Explicit, Size = 24)] + private struct PropVariant + { + private const ushort VtLpWstr = 31; + + [FieldOffset(0)] + public ushort VariantType; + + [FieldOffset(8)] + public IntPtr Value; + + public static PropVariant FromString(string value) + { + return new PropVariant + { + VariantType = VtLpWstr, + Value = Marshal.StringToCoTaskMemUni(value) + }; + } + + public string ReadString() + { + if (VariantType != VtLpWstr || Value == IntPtr.Zero) + return null; + + return Marshal.PtrToStringUni(Value); + } + } + + [ComImport] + [Guid("00021401-0000-0000-C000-000000000046")] + private class ShellLink; + + [ComImport] + [Guid("000214F9-0000-0000-C000-000000000046")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellLinkW + { + public void GetPath(IntPtr file, int fileCapacity, IntPtr findData, int flags); + + public void GetIDList(out IntPtr idList); + + public void SetIDList(IntPtr idList); + + public void GetDescription(IntPtr name, int nameCapacity); + + public void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string name); + + public void GetWorkingDirectory(IntPtr directory, int directoryCapacity); + + public void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string directory); + + public void GetArguments(IntPtr arguments, int argumentsCapacity); + + public void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string arguments); + + public void GetHotkey(out short hotkey); + + public void SetHotkey(short hotkey); + + public void GetShowCmd(out int showCommand); + + public void SetShowCmd(int showCommand); + + public void GetIconLocation(IntPtr iconPath, int iconPathCapacity, out int iconIndex); + + public void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string iconPath, int iconIndex); + + public void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string relativePath, int reserved); + + public void Resolve(IntPtr window, int flags); + + public void SetPath([MarshalAs(UnmanagedType.LPWStr)] string file); + } + + [ComImport] + [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IPropertyStore + { + public uint GetCount(); + + public PropertyKey GetAt(uint index); + + public void GetValue(ref PropertyKey key, out PropVariant value); + + public void SetValue(ref PropertyKey key, ref PropVariant value); + + public void Commit(); + } +} From 153a8e65e2b2c20a14764efc51fb8a7a24b6f431 Mon Sep 17 00:00:00 2001 From: ema Date: Tue, 13 Oct 2026 20:19:20 +0800 Subject: [PATCH 2/3] Add error handling for AppUserModel registration Wrap AppUserModel registration in a try-catch block for error handling when not in UWP. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- QuickLook/App.xaml.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs index 5bac92a49..7c906b6b2 100644 --- a/QuickLook/App.xaml.cs +++ b/QuickLook/App.xaml.cs @@ -61,7 +61,17 @@ static App() } // SMTC - AppUserModel.Register("QuickLook", "QuickLook"); + if (!IsUWP) + { + try + { + AppUserModel.Register("QuickLook", "QuickLook"); + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + } + } #if DEBUG if (SHCore.GetProcessDpiAwareness(0, out SHCore.PROCESS_DPI_AWARENESS awareness) == 0) From 474183c6608a37cdcabb6f7b9eaa70fa858fdccd Mon Sep 17 00:00:00 2001 From: ema Date: Tue, 13 Oct 2026 20:26:20 +0800 Subject: [PATCH 3/3] Clarify App startup comments for render mode, SMTC, and DPI. Co-authored-by: Cursor --- QuickLook/App.xaml.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs index 7c906b6b2..838e774e7 100644 --- a/QuickLook/App.xaml.cs +++ b/QuickLook/App.xaml.cs @@ -54,17 +54,19 @@ public partial class App : Application static App() { + // Set the process render mode based on the user setting var processRenderMode = SettingHelper.Get("ProcessRenderMode", failsafe: (int)RenderMode.Default, "QuickLook"); if (processRenderMode == (int)RenderMode.SoftwareOnly) { RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly; } - // SMTC + // Support for System Media Transport Controls (SMTC) if (!IsUWP) { try { + // Register an AppUserModelID so the media flyout identifies QuickLook AppUserModel.Register("QuickLook", "QuickLook"); } catch (Exception e) @@ -74,6 +76,7 @@ static App() } #if DEBUG + // Debug DPI awareness for the process if (SHCore.GetProcessDpiAwareness(0, out SHCore.PROCESS_DPI_AWARENESS awareness) == 0) Debug.WriteLine($"Process DPI awareness: {awareness} ({(int)awareness})"); else