From 2e1a5e4580bbed040f3d6fb52d59070e5581875b Mon Sep 17 00:00:00 2001 From: josuave Date: Tue, 18 Aug 2026 21:15:18 -0400 Subject: [PATCH 1/3] Refresh Explorer folder windows on shell change notifications Becoming the OS shell window (SetShellWindow, required for shell-mode operation) stops Explorer's own folder view windows from receiving shell-level change notifications, since that delivery path is restricted to whichever process the OS currently trusts as "the shell". Work around this by registering for the same notifications at interrupt level (not subject to that restriction) and manually refreshing any open Explorer window browsing an affected folder via the Shell.Application COM automation object. Fixes cairoshell/cairoshell#434 --- .../ManagedShell.Common.csproj | 4 + .../ExplorerRefreshWatcher.cs | 196 ++++++++++++++++++ .../SupportingClasses/ShellWindow.cs | 14 +- .../NativeMethods.Shell32.cs | 86 ++++++++ 4 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs diff --git a/src/ManagedShell.Common/ManagedShell.Common.csproj b/src/ManagedShell.Common/ManagedShell.Common.csproj index 68f0d2fe..7619307c 100644 --- a/src/ManagedShell.Common/ManagedShell.Common.csproj +++ b/src/ManagedShell.Common/ManagedShell.Common.csproj @@ -11,6 +11,10 @@ + + + + diff --git a/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs b/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs new file mode 100644 index 00000000..a06e9b88 --- /dev/null +++ b/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs @@ -0,0 +1,196 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows.Forms; +using ManagedShell.Common.Logging; +using static ManagedShell.Interop.NativeMethods; + +namespace ManagedShell.Common.SupportingClasses +{ + /// + /// Once another process registers itself as the OS shell window (via SetShellWindow, as + /// ShellWindow does), Explorer's own folder view windows stop receiving the shell-level + /// change notifications they rely on for auto-refresh, since that delivery path is + /// restricted to windows belonging to whichever process the OS currently trusts as "the + /// shell". This watcher registers for the same notifications at interrupt level, which is + /// not subject to that restriction, and manually refreshes any open Explorer windows + /// viewing an affected folder. + /// + public class ExplorerRefreshWatcher : IDisposable + { + private const SHCNE WatchedEvents = SHCNE.CREATE | SHCNE.DELETE | SHCNE.MKDIR | SHCNE.RMDIR | + SHCNE.RENAMEITEM | SHCNE.RENAMEFOLDER | SHCNE.UPDATEDIR | + SHCNE.UPDATEITEM | SHCNE.ATTRIBUTES | SHCNE.MEDIAINSERTED | + SHCNE.MEDIAREMOVED | SHCNE.DRIVEADD | SHCNE.DRIVEREMOVED; + + // Events whose item pidl(s) refer to the directory whose own listing needs refreshing. + // Everything else (create/delete/rename an item, make/remove a subfolder, rename a + // subfolder) targets an item *inside* a directory, so it's that item's parent whose + // window needs refreshing instead. + private const SHCNE DirectoryTargetEvents = SHCNE.UPDATEDIR | SHCNE.MEDIAINSERTED | + SHCNE.MEDIAREMOVED | SHCNE.DRIVEADD | SHCNE.DRIVEREMOVED; + + private readonly NativeWindowEx _window; + private readonly int _notifyMessage; + private IntPtr _registration; + private dynamic _shellApp; + + public ExplorerRefreshWatcher(NativeWindowEx window) + { + _window = window; + _notifyMessage = RegisterWindowMessage("ManagedShell_ExplorerRefreshWatcher"); + _window.MessageReceived += WndProc; + + SHChangeNotifyEntry entry = new SHChangeNotifyEntry + { + pIdl = IntPtr.Zero, + Recursively = true + }; + + _registration = SHChangeNotifyRegister(_window.Handle, SHCNRF.InterruptLevel | SHCNRF.NewDelivery, + WatchedEvents, (uint)_notifyMessage, 1, ref entry); + + if (_registration == IntPtr.Zero) + { + ShellLogger.Warning("ExplorerRefreshWatcher: Failed to register for shell change notifications"); + } + } + + private void WndProc(ref Message msg, ref bool handled) + { + if (msg.Msg != _notifyMessage) + { + return; + } + + IntPtr lockHandle = SHChangeNotification_Lock(msg.WParam, 0, out IntPtr pidlArray, out uint eventId); + + if (lockHandle == IntPtr.Zero) + { + return; + } + + try + { + bool isDirectoryTarget = (WatchedEvents & (SHCNE)eventId & DirectoryTargetEvents) != 0; + + string path1 = GetPathFromPidl(Marshal.ReadIntPtr(pidlArray)); + string path2 = GetPathFromPidl(Marshal.ReadIntPtr(pidlArray, IntPtr.Size)); + + string target1 = ResolveRefreshTarget(path1, isDirectoryTarget); + string target2 = ResolveRefreshTarget(path2, isDirectoryTarget); + + if (!string.IsNullOrEmpty(target1) || !string.IsNullOrEmpty(target2)) + { + RefreshExplorerWindows(target1, target2); + } + } + catch (Exception ex) + { + ShellLogger.Warning("ExplorerRefreshWatcher: Error handling shell change notification", ex); + } + finally + { + SHChangeNotification_Unlock(lockHandle); + } + } + + private static string GetPathFromPidl(IntPtr pidl) + { + if (pidl == IntPtr.Zero) + { + return null; + } + + StringBuilder path = new StringBuilder(260); + return SHGetPathFromIDList(pidl, path) ? path.ToString() : null; + } + + // For events where the pidl refers to an item inside a directory (create/delete/rename + // a file, make/remove/rename a subfolder), the Explorer window that needs refreshing is + // the one browsing that item's parent, not the item's own path. + private static string ResolveRefreshTarget(string path, bool isDirectoryTarget) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + if (isDirectoryTarget) + { + return path; + } + + try + { + return System.IO.Path.GetDirectoryName(path); + } + catch (ArgumentException) + { + return null; + } + } + + private void RefreshExplorerWindows(string target1, string target2) + { + try + { + _shellApp ??= Activator.CreateInstance(Type.GetTypeFromProgID("Shell.Application")); + dynamic windows = _shellApp.Windows(); + + foreach (dynamic window in windows) + { + try + { + string windowPath = window?.Document?.Folder?.Self?.Path as string; + + if (!string.IsNullOrEmpty(windowPath) && (PathsMatch(windowPath, target1) || PathsMatch(windowPath, target2))) + { + window.Refresh(); + } + } + catch (COMException) + { + // window doesn't expose a Document/Folder (e.g. an IE window), skip it + } + finally + { + if (window != null) + { + Marshal.ReleaseComObject(window); + } + } + } + + Marshal.ReleaseComObject(windows); + } + catch (Exception ex) + { + ShellLogger.Warning("ExplorerRefreshWatcher: Unable to refresh Explorer windows", ex); + } + } + + private static bool PathsMatch(string windowPath, string target) + { + return !string.IsNullOrEmpty(target) && + string.Equals(windowPath.TrimEnd('\\'), target.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); + } + + public void Dispose() + { + _window.MessageReceived -= WndProc; + + if (_registration != IntPtr.Zero) + { + SHChangeNotifyDeregister(_registration); + _registration = IntPtr.Zero; + } + + if (_shellApp != null) + { + Marshal.ReleaseComObject(_shellApp); + _shellApp = null; + } + } + } +} diff --git a/src/ManagedShell.Common/SupportingClasses/ShellWindow.cs b/src/ManagedShell.Common/SupportingClasses/ShellWindow.cs index ab51bb41..059127d3 100644 --- a/src/ManagedShell.Common/SupportingClasses/ShellWindow.cs +++ b/src/ManagedShell.Common/SupportingClasses/ShellWindow.cs @@ -10,6 +10,8 @@ public class ShellWindow : NativeWindowEx, IDisposable public EventHandler WallpaperChanged; public EventHandler WorkAreaChanged; + private ExplorerRefreshWatcher _explorerRefreshWatcher; + public ShellWindow() { CreateParams cp = new CreateParams(); @@ -32,18 +34,26 @@ public ShellWindow() { // we did it IsShellWindow = true; + + // Becoming the shell window breaks Explorer's own folder-view auto-refresh, since + // shell-level change notifications are only delivered to the trusted shell process. + // Work around that by relaying notifications to open Explorer windows ourselves. + _explorerRefreshWatcher = new ExplorerRefreshWatcher(this); } } public void SetSize() { - NativeMethods.SetWindowPos(Handle, IntPtr.Zero, SystemInformation.VirtualScreen.Left, - SystemInformation.VirtualScreen.Top, SystemInformation.VirtualScreen.Width, SystemInformation.VirtualScreen.Height, + NativeMethods.SetWindowPos(Handle, IntPtr.Zero, SystemInformation.VirtualScreen.Left, + SystemInformation.VirtualScreen.Top, SystemInformation.VirtualScreen.Width, SystemInformation.VirtualScreen.Height, (int)NativeMethods.SetWindowPosFlags.SWP_NOZORDER | (int)NativeMethods.SetWindowPosFlags.SWP_NOACTIVATE); } public void Dispose() { + _explorerRefreshWatcher?.Dispose(); + _explorerRefreshWatcher = null; + NativeMethods.DestroyWindow(Handle); } diff --git a/src/ManagedShell.Interop/NativeMethods.Shell32.cs b/src/ManagedShell.Interop/NativeMethods.Shell32.cs index d26bb3d9..b6b00ab9 100644 --- a/src/ManagedShell.Interop/NativeMethods.Shell32.cs +++ b/src/ManagedShell.Interop/NativeMethods.Shell32.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Text; namespace ManagedShell.Interop { @@ -696,5 +697,90 @@ public enum KnownFolderFlags : uint NoAppcontainerRedirection = 0x00010000, AliasOnly = 0x80000000 } + + #region Shell change notifications (SHChangeNotifyRegister) + + [StructLayout(LayoutKind.Sequential)] + public struct SHChangeNotifyEntry + { + public IntPtr pIdl; + [MarshalAs(UnmanagedType.Bool)] + public bool Recursively; + } + + [Flags] + public enum SHCNRF : uint + { + InterruptLevel = 0x0001, + ShellLevel = 0x0002, + RecursiveInterrupt = 0x1000, + NewDelivery = 0x8000 + } + + [Flags] + public enum SHCNE : long + { + RENAMEITEM = 0x00000001L, + CREATE = 0x00000002L, + DELETE = 0x00000004L, + MKDIR = 0x00000008L, + RMDIR = 0x00000010L, + MEDIAINSERTED = 0x00000020L, + MEDIAREMOVED = 0x00000040L, + DRIVEREMOVED = 0x00000080L, + DRIVEADD = 0x00000100L, + NETSHARE = 0x00000200L, + NETUNSHARE = 0x00000400L, + ATTRIBUTES = 0x00000800L, + UPDATEDIR = 0x00001000L, + UPDATEITEM = 0x00002000L, + SERVERDISCONNECT = 0x00004000L, + UPDATEIMAGE = 0x00008000L, + DRIVEADDGUI = 0x00010000L, + RENAMEFOLDER = 0x00020000L, + FREESPACE = 0x00040000L, + EXTENDED_EVENT = 0x04000000L, + ASSOCCHANGED = 0x08000000L, + DISKEVENTS = 0x0002381FL, + GLOBALEVENTS = 0x0C0581E0L, + ALLEVENTS = 0x7FFFFFFFL, + INTERRUPT = unchecked((long)0x80000000L) + } + + [Flags] + public enum SHCNF : uint + { + IDLIST = 0x0000, + PATHA = 0x0001, + PRINTERA = 0x0002, + DWORD = 0x0003, + PATHW = 0x0005, + PRINTERW = 0x0006, + TYPE = 0x00FF, + FLUSH = 0x1000, + FLUSHNOWAIT = 0x2000 + } + + // Registers a window to receive shell change notifications. Using SHCNRF_InterruptLevel + // (rather than SHCNRF_ShellLevel) avoids the OS's "trusted shell process" delivery + // restriction that normal shell-level listeners are subject to. + [DllImport(Shell32_DllName, CharSet = CharSet.Auto)] + public static extern IntPtr SHChangeNotifyRegister(IntPtr hWnd, SHCNRF fSources, SHCNE fEvents, uint wMsg, int cEntries, ref SHChangeNotifyEntry pFsne); + + [DllImport(Shell32_DllName)] + public static extern bool SHChangeNotifyDeregister(IntPtr hNotify); + + // Undocumented, ordinal-exported helpers used to decode the shared-memory payload + // delivered with the registered notification window message. + [DllImport(Shell32_DllName, EntryPoint = "#644")] + public static extern IntPtr SHChangeNotification_Lock(IntPtr wParam, int dwProcessId, out IntPtr pidlArray, out uint lEvent); + + [DllImport(Shell32_DllName, EntryPoint = "#645")] + public static extern bool SHChangeNotification_Unlock(IntPtr hLock); + + [DllImport(Shell32_DllName, CharSet = CharSet.Auto)] + public static extern bool SHGetPathFromIDList(IntPtr pidl, StringBuilder pszPath); + + #endregion } } From 088e2cc870acae8b0db2fac3a1a7293fc4ee5ebb Mon Sep 17 00:00:00 2001 From: josuave Date: Wed, 19 Aug 2026 20:16:48 -0400 Subject: [PATCH 2/3] Use documented name-based import for SHChangeNotification_Lock/Unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are documented in shlobj_core.h (Shell32.lib) and exported by name from Shell32.dll since Windows 10, contrary to the prior comment — no need for the ordinal EntryPoint workaround from pre-documentation era shell programming. --- src/ManagedShell.Interop/NativeMethods.Shell32.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ManagedShell.Interop/NativeMethods.Shell32.cs b/src/ManagedShell.Interop/NativeMethods.Shell32.cs index b6b00ab9..c7d0a284 100644 --- a/src/ManagedShell.Interop/NativeMethods.Shell32.cs +++ b/src/ManagedShell.Interop/NativeMethods.Shell32.cs @@ -770,12 +770,13 @@ public enum SHCNF : uint [DllImport(Shell32_DllName)] public static extern bool SHChangeNotifyDeregister(IntPtr hNotify); - // Undocumented, ordinal-exported helpers used to decode the shared-memory payload - // delivered with the registered notification window message. - [DllImport(Shell32_DllName, EntryPoint = "#644")] - public static extern IntPtr SHChangeNotification_Lock(IntPtr wParam, int dwProcessId, out IntPtr pidlArray, out uint lEvent); + // Decodes the shared-memory payload delivered with the registered notification window + // message. Documented in shlobj_core.h (see SHChangeNotification_Lock/_Unlock on + // Microsoft Learn); Shell32.dll has exported these by name since Windows 10. + [DllImport(Shell32_DllName)] + public static extern IntPtr SHChangeNotification_Lock(IntPtr hChange, int dwProcId, out IntPtr pidlArray, out uint lEvent); - [DllImport(Shell32_DllName, EntryPoint = "#645")] + [DllImport(Shell32_DllName)] public static extern bool SHChangeNotification_Unlock(IntPtr hLock); [DllImport(Shell32_DllName, CharSet = CharSet.Auto)] From b63e046af2ce63f3f74036eebfe7aaac41a0c9d8 Mon Sep 17 00:00:00 2001 From: josuave Date: Wed, 19 Aug 2026 20:29:32 -0400 Subject: [PATCH 3/3] Fix P/Invoke type mismatches against documented signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SHChangeNotifyRegister returns a ULONG registration ID (not a handle); SHChangeNotifyDeregister takes a ULONG. Both were declared as IntPtr, which happened to work on x64 by register- passing coincidence but didn't match the documented contract. - SHCNE's underlying type was C# long (64-bit); the native fEvents parameter is LONG (32-bit). - SHChangeNotification_Lock's dwProcId parameter must be the notification message's lParam when registered with SHCNRF_NewDelivery (as this code does), not a hardcoded 0 — per the function's documented remarks. Also corrects a comment that called SHChangeNotification_Lock/Unlock "undocumented, ordinal-exported" — they're documented in shlobj_core.h and exported by name from Shell32.dll. --- .../ExplorerRefreshWatcher.cs | 28 ++++---- .../NativeMethods.Shell32.cs | 65 ++++++++++--------- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs b/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs index a06e9b88..2281b70d 100644 --- a/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs +++ b/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs @@ -8,13 +8,15 @@ namespace ManagedShell.Common.SupportingClasses { /// - /// Once another process registers itself as the OS shell window (via SetShellWindow, as - /// ShellWindow does), Explorer's own folder view windows stop receiving the shell-level - /// change notifications they rely on for auto-refresh, since that delivery path is - /// restricted to windows belonging to whichever process the OS currently trusts as "the - /// shell". This watcher registers for the same notifications at interrupt level, which is - /// not subject to that restriction, and manually refreshes any open Explorer windows - /// viewing an affected folder. + /// Once ShellWindow registers itself as the OS shell window (via SetShellWindow), Explorer's + /// own folder view windows stop receiving the SHCNRF_ShellLevel change notifications they + /// rely on for auto-refresh. The exact reason isn't documented, but it's reproducible and + /// scoped specifically to SetShellWindow (confirmed against cairoshell/cairoshell#434) — the + /// working theory is that shell-level delivery is restricted to windows belonging to + /// whichever process the OS currently considers "the shell". SHCNRF_InterruptLevel + /// notifications (meant for background/service-style listeners) aren't subject to whatever + /// that restriction is, so this watcher registers for those instead and manually refreshes + /// any open Explorer window viewing an affected folder. /// public class ExplorerRefreshWatcher : IDisposable { @@ -32,7 +34,7 @@ public class ExplorerRefreshWatcher : IDisposable private readonly NativeWindowEx _window; private readonly int _notifyMessage; - private IntPtr _registration; + private uint _registration; private dynamic _shellApp; public ExplorerRefreshWatcher(NativeWindowEx window) @@ -50,7 +52,7 @@ public ExplorerRefreshWatcher(NativeWindowEx window) _registration = SHChangeNotifyRegister(_window.Handle, SHCNRF.InterruptLevel | SHCNRF.NewDelivery, WatchedEvents, (uint)_notifyMessage, 1, ref entry); - if (_registration == IntPtr.Zero) + if (_registration == 0) { ShellLogger.Warning("ExplorerRefreshWatcher: Failed to register for shell change notifications"); } @@ -63,7 +65,9 @@ private void WndProc(ref Message msg, ref bool handled) return; } - IntPtr lockHandle = SHChangeNotification_Lock(msg.WParam, 0, out IntPtr pidlArray, out uint eventId); + // Registered with SHCNRF_NewDelivery, so per SHChangeNotification_Lock's documented + // contract, wParam/lParam from the message map to its hChange/dwProcId parameters. + IntPtr lockHandle = SHChangeNotification_Lock(msg.WParam, unchecked((int)(long)msg.LParam), out IntPtr pidlArray, out uint eventId); if (lockHandle == IntPtr.Zero) { @@ -180,10 +184,10 @@ public void Dispose() { _window.MessageReceived -= WndProc; - if (_registration != IntPtr.Zero) + if (_registration != 0) { SHChangeNotifyDeregister(_registration); - _registration = IntPtr.Zero; + _registration = 0; } if (_shellApp != null) diff --git a/src/ManagedShell.Interop/NativeMethods.Shell32.cs b/src/ManagedShell.Interop/NativeMethods.Shell32.cs index c7d0a284..f64f4204 100644 --- a/src/ManagedShell.Interop/NativeMethods.Shell32.cs +++ b/src/ManagedShell.Interop/NativeMethods.Shell32.cs @@ -717,34 +717,36 @@ public enum SHCNRF : uint NewDelivery = 0x8000 } + // Underlying type is int to match the native fEvents parameter, which is LONG + // (32-bit) per SHChangeNotifyRegister's documented signature. [Flags] - public enum SHCNE : long + public enum SHCNE { - RENAMEITEM = 0x00000001L, - CREATE = 0x00000002L, - DELETE = 0x00000004L, - MKDIR = 0x00000008L, - RMDIR = 0x00000010L, - MEDIAINSERTED = 0x00000020L, - MEDIAREMOVED = 0x00000040L, - DRIVEREMOVED = 0x00000080L, - DRIVEADD = 0x00000100L, - NETSHARE = 0x00000200L, - NETUNSHARE = 0x00000400L, - ATTRIBUTES = 0x00000800L, - UPDATEDIR = 0x00001000L, - UPDATEITEM = 0x00002000L, - SERVERDISCONNECT = 0x00004000L, - UPDATEIMAGE = 0x00008000L, - DRIVEADDGUI = 0x00010000L, - RENAMEFOLDER = 0x00020000L, - FREESPACE = 0x00040000L, - EXTENDED_EVENT = 0x04000000L, - ASSOCCHANGED = 0x08000000L, - DISKEVENTS = 0x0002381FL, - GLOBALEVENTS = 0x0C0581E0L, - ALLEVENTS = 0x7FFFFFFFL, - INTERRUPT = unchecked((long)0x80000000L) + RENAMEITEM = 0x00000001, + CREATE = 0x00000002, + DELETE = 0x00000004, + MKDIR = 0x00000008, + RMDIR = 0x00000010, + MEDIAINSERTED = 0x00000020, + MEDIAREMOVED = 0x00000040, + DRIVEREMOVED = 0x00000080, + DRIVEADD = 0x00000100, + NETSHARE = 0x00000200, + NETUNSHARE = 0x00000400, + ATTRIBUTES = 0x00000800, + UPDATEDIR = 0x00001000, + UPDATEITEM = 0x00002000, + SERVERDISCONNECT = 0x00004000, + UPDATEIMAGE = 0x00008000, + DRIVEADDGUI = 0x00010000, + RENAMEFOLDER = 0x00020000, + FREESPACE = 0x00040000, + EXTENDED_EVENT = 0x04000000, + ASSOCCHANGED = 0x08000000, + DISKEVENTS = 0x0002381F, + GLOBALEVENTS = 0x0C0581E0, + ALLEVENTS = 0x7FFFFFFF, + INTERRUPT = unchecked((int)0x80000000) } [Flags] @@ -761,14 +763,15 @@ public enum SHCNF : uint FLUSHNOWAIT = 0x2000 } - // Registers a window to receive shell change notifications. Using SHCNRF_InterruptLevel - // (rather than SHCNRF_ShellLevel) avoids the OS's "trusted shell process" delivery - // restriction that normal shell-level listeners are subject to. + // Registers a window to receive shell change notifications. SHCNRF_InterruptLevel + // notifications are not restricted to Explorer's own folder views the way + // SHCNRF_ShellLevel notifications appear to be in practice (see ExplorerRefreshWatcher). + // Return value is a ULONG registration ID, not a handle. [DllImport(Shell32_DllName, CharSet = CharSet.Auto)] - public static extern IntPtr SHChangeNotifyRegister(IntPtr hWnd, SHCNRF fSources, SHCNE fEvents, uint wMsg, int cEntries, ref SHChangeNotifyEntry pFsne); + public static extern uint SHChangeNotifyRegister(IntPtr hWnd, SHCNRF fSources, SHCNE fEvents, uint wMsg, int cEntries, ref SHChangeNotifyEntry pFsne); [DllImport(Shell32_DllName)] - public static extern bool SHChangeNotifyDeregister(IntPtr hNotify); + public static extern bool SHChangeNotifyDeregister(uint ulID); // Decodes the shared-memory payload delivered with the registered notification window // message. Documented in shlobj_core.h (see SHChangeNotification_Lock/_Unlock on