Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/ManagedShell.Common/ManagedShell.Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<PackageReference Include="System.Data.OleDb" Version="8.0.1" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<Reference Include="Microsoft.CSharp" />
</ItemGroup>

<ItemGroup>
<Resource Include="Resources\nullIcon.png" />
</ItemGroup>
Expand Down
200 changes: 200 additions & 0 deletions src/ManagedShell.Common/SupportingClasses/ExplorerRefreshWatcher.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
}
14 changes: 12 additions & 2 deletions src/ManagedShell.Common/SupportingClasses/ShellWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public class ShellWindow : NativeWindowEx, IDisposable
public EventHandler WallpaperChanged;
public EventHandler WorkAreaChanged;

private ExplorerRefreshWatcher _explorerRefreshWatcher;

public ShellWindow()
{
CreateParams cp = new CreateParams();
Expand All @@ -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);
}

Expand Down
90 changes: 90 additions & 0 deletions src/ManagedShell.Interop/NativeMethods.Shell32.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

namespace ManagedShell.Interop
{
Expand Down Expand Up @@ -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
}
}