From d6741fe6dd6d09eb22ebf132a674a689104c77b1 Mon Sep 17 00:00:00 2001 From: 0x5BFA <62196528+0x5bfa@users.noreply.github.com> Date: Fri, 9 May 2025 15:40:15 +0900 Subject: [PATCH] Init --- src/Files.App.CsWin32/NativeMethods.txt | 9 + .../WindowsStorage/IWindowsFolder.cs | 2 +- .../WindowsStorage/IWindowsFolderWatcher.cs | 41 +++ .../Storables/WindowsStorage/WindowsFolder.cs | 9 + .../WindowsStorage/WindowsFolderWatcher.cs | 298 ++++++++++++++++++ .../WindowsFolderWatcherEventArgs.cs | 23 ++ .../WindowsStorage/WindowsStorable.cs | 12 + .../Widgets/QuickAccessWidgetViewModel.cs | 19 +- src/Files.Shared/Utils/Debouncer.cs | 57 ++++ 9 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolderWatcher.cs create mode 100644 src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcher.cs create mode 100644 src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcherEventArgs.cs create mode 100644 src/Files.Shared/Utils/Debouncer.cs diff --git a/src/Files.App.CsWin32/NativeMethods.txt b/src/Files.App.CsWin32/NativeMethods.txt index 294dcf68ab36..335d1afbbd1d 100644 --- a/src/Files.App.CsWin32/NativeMethods.txt +++ b/src/Files.App.CsWin32/NativeMethods.txt @@ -236,3 +236,12 @@ GetMenuItemCount GetMenuItemInfo IsWow64Process2 GetCurrentProcess +SHChangeNotifyRegister +SHChangeNotifyDeregister +SHChangeNotification_Lock +SHChangeNotification_Unlock +CoInitialize +CoUninitialize +PostQuitMessage +HWND_MESSAGE +SHCNE_ID diff --git a/src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolder.cs b/src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolder.cs index 92160da97f48..2d6454ebbee6 100644 --- a/src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolder.cs +++ b/src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolder.cs @@ -5,7 +5,7 @@ namespace Files.App.Storage { - public unsafe interface IWindowsFolder : IWindowsStorable, IChildFolder + public unsafe interface IWindowsFolder : IWindowsStorable, IChildFolder, IMutableFolder { /// /// Gets or sets the cached for the ShellNew context menu. diff --git a/src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolderWatcher.cs b/src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolderWatcher.cs new file mode 100644 index 000000000000..4b1e7972d173 --- /dev/null +++ b/src/Files.App.Storage/Storables/WindowsStorage/IWindowsFolderWatcher.cs @@ -0,0 +1,41 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Windows.Foundation; + +namespace Files.App.Storage +{ + public interface IWindowsFolderWatcher : IFolderWatcher + { + public event TypedEventHandler? EventOccurred; + + public event TypedEventHandler? ItemAssocChanged; // SHCNE_ASSOCCHANGED + public event TypedEventHandler? ItemAttributesChanged; // SHCNE_ATTRIBUTES + public event TypedEventHandler? ItemImageUpdated; // SHCNE_UPDATEIMAGE + + public event TypedEventHandler? FileRenamed; // SHCNE_RENAMEITEM + public event TypedEventHandler? FileCreated; // SHCNE_CREATE + public event TypedEventHandler? FileDeleted; // SHCNE_DELETE + public event TypedEventHandler? FileUpdated; // SHCNE_UPDATEITEM + + public event TypedEventHandler? FolderRenamed; // SHCNE_RENAMEFOLDER + public event TypedEventHandler? FolderCreated; // SHCNE_MKDIR + public event TypedEventHandler? FolderDeleted; // SHCNE_RMDIR + public event TypedEventHandler? FolderUpdated; // SHCNE_UPDATEDIR + + public event TypedEventHandler? MediaInserted; // SHCNE_MEDIAINSERTED + public event TypedEventHandler? MediaRemoved; // SHCNE_MEDIAREMOVED + public event TypedEventHandler? DriveRemoved; // SHCNE_DRIVEREMOVED + public event TypedEventHandler? DriveAdded; // SHCNE_DRIVEADD + public event TypedEventHandler? DriveAddedViaGUI; // SHCNE_DRIVEADDGUI + public event TypedEventHandler? FreeSpaceUpdated; // SHCNE_FREESPACE + + public event TypedEventHandler? SharingStarted; // SHCNE_NETSHARE + public event TypedEventHandler? SharingStopped; // SHCNE_NETUNSHARE + + public event TypedEventHandler? DisconnectedFromServer; // SHCNE_SERVERDISCONNECT + + public event TypedEventHandler? ExtendedEventOccurred; // SHCNE_EXTENDED_EVENT + public event TypedEventHandler? SystemInterruptOccurred; // SHCNE_INTERRUPT + } +} diff --git a/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolder.cs b/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolder.cs index 510300fa5f6d..5ed2feba137c 100644 --- a/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolder.cs +++ b/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolder.cs @@ -44,6 +44,7 @@ public WindowsFolder(Guid folderId) ThisPtr = pShellItem; } + /// public IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, CancellationToken cancellationToken = default) { using ComPtr pEnumShellItems = default; @@ -75,6 +76,14 @@ public IAsyncEnumerable GetItemsAsync(StorableType type = Storab return childItems.ToAsyncEnumerable(); } + /// + public Task GetFolderWatcherAsync(CancellationToken cancellationToken = default) + { + IFolderWatcher watcher = new WindowsFolderWatcher(this); + return Task.FromResult(watcher); + } + + /// public override void Dispose() { base.Dispose(); diff --git a/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcher.cs b/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcher.cs new file mode 100644 index 000000000000..d0fe8c477cb4 --- /dev/null +++ b/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcher.cs @@ -0,0 +1,298 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Utils; +using System.Collections.Specialized; +using System.Runtime.InteropServices; +using Windows.Foundation; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.Shell.Common; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Files.App.Storage +{ + /// + /// Represents an implementation of that uses Windows Shell notifications to watch for changes in a folder. + /// + public unsafe partial class WindowsFolderWatcher : IWindowsFolderWatcher + { + // Fields + + private const uint WM_NOTIFYFOLDERCHANGE = PInvoke.WM_APP | 0x0001U; + private readonly WNDPROC _wndProc; + + private uint _watcherRegID = 0U; + private ITEMIDLIST* _folderPidl = default; + private Debouncer _debouncer; + + // Properties + + public IMutableFolder Folder { get; private set; } + + // Events + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public event TypedEventHandler? EventOccurred; + + public event TypedEventHandler? ItemAssocChanged; // SHCNE_ASSOCCHANGED + public event TypedEventHandler? ItemAttributesChanged; // SHCNE_ATTRIBUTES + public event TypedEventHandler? ItemImageUpdated; // SHCNE_UPDATEIMAGE + + public event TypedEventHandler? FileRenamed; // SHCNE_RENAMEITEM + public event TypedEventHandler? FileCreated; // SHCNE_CREATE + public event TypedEventHandler? FileDeleted; // SHCNE_DELETE + public event TypedEventHandler? FileUpdated; // SHCNE_UPDATEITEM + + public event TypedEventHandler? FolderRenamed; // SHCNE_RENAMEFOLDER + public event TypedEventHandler? FolderCreated; // SHCNE_MKDIR + public event TypedEventHandler? FolderDeleted; // SHCNE_RMDIR + public event TypedEventHandler? FolderUpdated; // SHCNE_UPDATEDIR + + public event TypedEventHandler? MediaInserted; // SHCNE_MEDIAINSERTED + public event TypedEventHandler? MediaRemoved; // SHCNE_MEDIAREMOVED + public event TypedEventHandler? DriveRemoved; // SHCNE_DRIVEREMOVED + public event TypedEventHandler? DriveAdded; // SHCNE_DRIVEADD + public event TypedEventHandler? DriveAddedViaGUI; // SHCNE_DRIVEADDGUI + public event TypedEventHandler? FreeSpaceUpdated; // SHCNE_FREESPACE + + public event TypedEventHandler? SharingStarted; // SHCNE_NETSHARE + public event TypedEventHandler? SharingStopped; // SHCNE_NETUNSHARE + + public event TypedEventHandler? DisconnectedFromServer; // SHCNE_SERVERDISCONNECT + + public event TypedEventHandler? ExtendedEventOccurred; // SHCNE_EXTENDED_EVENT + public event TypedEventHandler? SystemInterruptOccurred; // SHCNE_INTERRUPT + + // Constructor + + /// Initializes a new instance of the class. + /// Specifies the folder to be monitored for changes. + public WindowsFolderWatcher(WindowsFolder folder, int debounceMilliseconds = 1000) + { + _debouncer = new(debounceMilliseconds); + + Folder = folder; + + fixed (char* pszClassName = $"FolderWatcherWindowClass{Guid.NewGuid():B}") + { + _wndProc = new(WndProc); + + WNDCLASSEXW wndClass = default; + wndClass.cbSize = (uint)sizeof(WNDCLASSEXW); + wndClass.lpfnWndProc = (delegate* unmanaged[Stdcall])Marshal.GetFunctionPointerForDelegate(_wndProc); + wndClass.hInstance = PInvoke.GetModuleHandle(default(PWSTR)); + wndClass.lpszClassName = pszClassName; + + PInvoke.RegisterClassEx(&wndClass); + PInvoke.CreateWindowEx(0, pszClassName, null, 0, 0, 0, 0, 0, HWND.HWND_MESSAGE, default, wndClass.hInstance, null); + } + } + + // Methods + + private unsafe LRESULT WndProc(HWND hWnd, uint uMessage, WPARAM wParam, LPARAM lParam) + { + switch (uMessage) + { + case PInvoke.WM_CREATE: + { + PInvoke.CoInitialize(); + + ITEMIDLIST* pidl = default; + IWindowsFolder folder = (IWindowsFolder)Folder; + PInvoke.SHGetIDListFromObject((IUnknown*)folder.ThisPtr, &pidl); + _folderPidl = pidl; + + SHChangeNotifyEntry changeNotifyEntry = default; + changeNotifyEntry.pidl = pidl; + + _watcherRegID = PInvoke.SHChangeNotifyRegister( + hWnd, + SHCNRF_SOURCE.SHCNRF_ShellLevel | SHCNRF_SOURCE.SHCNRF_NewDelivery, + (int)SHCNE_ID.SHCNE_ALLEVENTS, + WM_NOTIFYFOLDERCHANGE, + 1, + &changeNotifyEntry); + + if (_watcherRegID is 0U) + break; + } + break; + case WM_NOTIFYFOLDERCHANGE: + { + ITEMIDLIST** ppidl; + uint lEvent = 0; + HANDLE hLock = PInvoke.SHChangeNotification_Lock((HANDLE)(nint)wParam.Value, (uint)lParam.Value, &ppidl, (int*)&lEvent); + + if (hLock.IsNull) + break; + + ITEMIDLIST* pOldPidl = ppidl[0]; + ITEMIDLIST* pNewPidl = ppidl[1]; + + SHCNE_ID eventType = (SHCNE_ID)lEvent; + var oldItem = WindowsStorable.TryParse(pOldPidl); + var newItem = WindowsStorable.TryParse(pNewPidl); + + _debouncer.Debounce(() => + { + FireEvent(eventType, oldItem, newItem); + }); + + PInvoke.SHChangeNotification_Unlock(hLock); + + PInvoke.CoTaskMemFree(pOldPidl); + PInvoke.CoTaskMemFree(pNewPidl); + } + break; + case PInvoke.WM_DESTROY: + { + Dispose(); + } + break; + } + + return PInvoke.DefWindowProc(hWnd, uMessage, wParam, lParam); + } + + private void FireEvent(SHCNE_ID eventType, IWindowsStorable? oldItem, IWindowsStorable? newItem) + { + EventOccurred?.Invoke(this, new(eventType, oldItem, newItem)); + + switch (eventType) + { + case SHCNE_ID.SHCNE_ASSOCCHANGED: + { + ItemAssocChanged?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_ATTRIBUTES: + { + ItemAttributesChanged?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_UPDATEIMAGE: + { + ItemImageUpdated?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_RENAMEITEM: + { + FileRenamed?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_CREATE: + { + FileCreated?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_DELETE: + { + FileDeleted?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_UPDATEITEM: + { + FileUpdated?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_RENAMEFOLDER: + { + FolderRenamed?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_MKDIR: + { + FolderCreated?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_RMDIR: + { + FolderDeleted?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_UPDATEDIR: + { + FolderUpdated?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_MEDIAINSERTED: + { + MediaInserted?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_MEDIAREMOVED: + { + MediaRemoved?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_DRIVEREMOVED: + { + DriveRemoved?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_DRIVEADD: + { + DriveAdded?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_DRIVEADDGUI: + { + DriveAddedViaGUI?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_FREESPACE: + { + FreeSpaceUpdated?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_NETSHARE: + { + SharingStarted?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_NETUNSHARE: + { + SharingStopped?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_SERVERDISCONNECT: + { + DisconnectedFromServer?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_EXTENDED_EVENT: + { + ExtendedEventOccurred?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + case SHCNE_ID.SHCNE_INTERRUPT: + { + SystemInterruptOccurred?.Invoke(this, new(eventType, oldItem, newItem)); + } + break; + } + } + + // Disposers + + public void Dispose() + { + PInvoke.SHChangeNotifyDeregister(_watcherRegID); + PInvoke.CoTaskMemFree(_folderPidl); + PInvoke.CoUninitialize(); + PInvoke.PostQuitMessage(0); + } + + public ValueTask DisposeAsync() + { + Dispose(); + + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcherEventArgs.cs b/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcherEventArgs.cs new file mode 100644 index 000000000000..69158c39a26b --- /dev/null +++ b/src/Files.App.Storage/Storables/WindowsStorage/WindowsFolderWatcherEventArgs.cs @@ -0,0 +1,23 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Windows.Win32.UI.Shell; + +namespace Files.App.Storage +{ + public class WindowsFolderWatcherEventArgs : EventArgs + { + public SHCNE_ID EventType { get; init; } + + public IWindowsStorable? OldItem { get; init; } + + public IWindowsStorable? NewItem { get; init; } + + public WindowsFolderWatcherEventArgs(SHCNE_ID eventType, IWindowsStorable? _oldItem = null, IWindowsStorable? _newItem = null) + { + EventType = eventType; + OldItem = _oldItem; + NewItem = _newItem; + } + } +} diff --git a/src/Files.App.Storage/Storables/WindowsStorage/WindowsStorable.cs b/src/Files.App.Storage/Storables/WindowsStorage/WindowsStorable.cs index bde2995b6490..9b787756fd10 100644 --- a/src/Files.App.Storage/Storables/WindowsStorage/WindowsStorable.cs +++ b/src/Files.App.Storage/Storables/WindowsStorage/WindowsStorable.cs @@ -6,6 +6,7 @@ using Windows.Win32.Foundation; using Windows.Win32.System.SystemServices; using Windows.Win32.UI.Shell; +using Windows.Win32.UI.Shell.Common; namespace Files.App.Storage { @@ -47,6 +48,17 @@ public IContextMenu* ContextMenu return TryParse(pShellItem); } + public static unsafe WindowsStorable? TryParse(ITEMIDLIST* pidl) + { + IShellItem* pShellItem = default; + + HRESULT hr = PInvoke.SHCreateItemFromIDList(pidl, IID.IID_IShellItem, (void**)&pShellItem); + if (hr.ThrowIfFailedOnDebug().Failed || pShellItem is null) + return null; + + return TryParse(pShellItem); + } + public static WindowsStorable? TryParse(IShellItem* pShellItem) { bool isFolder = pShellItem->GetAttributes(SFGAO_FLAGS.SFGAO_FOLDER, out var returnedAttributes).Succeeded && returnedAttributes is SFGAO_FLAGS.SFGAO_FOLDER; diff --git a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs index fc447eedbe95..080039e080e5 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs @@ -33,8 +33,7 @@ public sealed partial class QuickAccessWidgetViewModel : BaseWidgetViewModel, IW // Fields - // TODO: Replace with IMutableFolder.GetWatcherAsync() once it gets implemented in IWindowsStorable - private readonly SystemIO.FileSystemWatcher _quickAccessFolderWatcher; + private readonly IWindowsFolderWatcher _watcher; // Constructor @@ -46,19 +45,9 @@ public QuickAccessWidgetViewModel() PinToSidebarCommand = new AsyncRelayCommand(ExecutePinToSidebarCommand); UnpinFromSidebarCommand = new AsyncRelayCommand(ExecuteUnpinFromSidebarCommand); - _quickAccessFolderWatcher = new() - { - Path = SystemIO.Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft", "Windows", "Recent", "AutomaticDestinations"), - Filter = "f01b4d95cf55d32a.automaticDestinations-ms", - NotifyFilter = SystemIO.NotifyFilters.LastAccess | SystemIO.NotifyFilters.LastWrite | SystemIO.NotifyFilters.FileName - }; - - _quickAccessFolderWatcher.Changed += async (s, e) => - { - await RefreshWidgetAsync(); - }; - - _quickAccessFolderWatcher.EnableRaisingEvents = true; + var quickAccessFolder = new WindowsFolder(new Guid("3936e9e4-d92c-4eee-a85a-bc16d5ea0819")); + _watcher = (IWindowsFolderWatcher)quickAccessFolder.GetFolderWatcherAsync(default).Result; + _watcher.EventOccurred += async (s, e) => { await RefreshWidgetAsync(); }; } // Methods diff --git a/src/Files.Shared/Utils/Debouncer.cs b/src/Files.Shared/Utils/Debouncer.cs new file mode 100644 index 000000000000..e1ee4bfa3736 --- /dev/null +++ b/src/Files.Shared/Utils/Debouncer.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Shared.Utils +{ + public class Debouncer + { + private List _stepperCancelTokens = new(); + private readonly int _millisecondsToWait; + private readonly object _lockThis = new(); // Use a locking object to prevent the debouncer to trigger again while the func is still running + + public Debouncer(int millisecondsToWait = 300) + { + _millisecondsToWait = millisecondsToWait; + } + + public void Debounce(Action func) + { + CancelAllStepperTokens(); + CancellationTokenSource newTokenSrc = new(); + + lock (_lockThis) + { + _stepperCancelTokens.Add(newTokenSrc); + } + + _ = Task.Delay(_millisecondsToWait, newTokenSrc.Token).ContinueWith(task => // Create new request + { + if (!newTokenSrc.IsCancellationRequested) // if it has not been cancelled + { + CancelAllStepperTokens(); // Cancel any that remain (there shouldn't be any) + _stepperCancelTokens = new(); + + lock (_lockThis) + { + func(); // run + } + } + }, TaskScheduler.FromCurrentSynchronizationContext()); + } + + private void CancelAllStepperTokens() + { + foreach (CancellationTokenSource token in _stepperCancelTokens) + { + if (!token.IsCancellationRequested) + { + token.Cancel(); + } + } + } + } +}