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 } }