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..2281b70d
--- /dev/null
+++ b/src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs
@@ -0,0 +1,200 @@
+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 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
+ {
+ 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 uint _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 == 0)
+ {
+ ShellLogger.Warning("ExplorerRefreshWatcher: Failed to register for shell change notifications");
+ }
+ }
+
+ private void WndProc(ref Message msg, ref bool handled)
+ {
+ if (msg.Msg != _notifyMessage)
+ {
+ return;
+ }
+
+ // 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)
+ {
+ 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 != 0)
+ {
+ SHChangeNotifyDeregister(_registration);
+ _registration = 0;
+ }
+
+ 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..f64f4204 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,94 @@ 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
+ }
+
+ // Underlying type is int to match the native fEvents parameter, which is LONG
+ // (32-bit) per SHChangeNotifyRegister's documented signature.
+ [Flags]
+ public enum SHCNE
+ {
+ 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]
+ 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. 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 uint SHChangeNotifyRegister(IntPtr hWnd, SHCNRF fSources, SHCNE fEvents, uint wMsg, int cEntries, ref SHChangeNotifyEntry pFsne);
+
+ [DllImport(Shell32_DllName)]
+ 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
+ // 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)]
+ public static extern bool SHChangeNotification_Unlock(IntPtr hLock);
+
+ [DllImport(Shell32_DllName, CharSet = CharSet.Auto)]
+ public static extern bool SHGetPathFromIDList(IntPtr pidl, StringBuilder pszPath);
+
+ #endregion
}
}