From b9ec86e52fe697635b300ca0db460874a50283f7 Mon Sep 17 00:00:00 2001 From: mohamadsm313m-prog Date: Fri, 13 Nov 2026 17:00:15 +0330 Subject: [PATCH 1/6] Empty the Recycle Bin in a detached background process SHEmptyRecycleBin used to run inside the viewer process, so closing the preview window or previewing another file aborted the deletion. The panel now starts QuickLook.exe with a hidden --empty-recycle-bin argument; that helper empties the bin with no UI, persists after the viewer is gone and is guarded by a cross-process mutex. The panel shows an emptying state and refreshes itself afterwards. --- .../RecycleBinPanel.xaml.cs | 117 ++++++++--- .../Translations.config | 29 +++ QuickLook/App.xaml.cs | 21 ++ QuickLook/Helpers/RecycleBinCleaner.cs | 185 ++++++++++++++++++ 4 files changed, 325 insertions(+), 27 deletions(-) create mode 100644 QuickLook/Helpers/RecycleBinCleaner.cs diff --git a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs index e52a85cb1..398a2c2e5 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs @@ -69,23 +69,104 @@ protected virtual void OnLoaded(object sender, RoutedEventArgs e) }); } - private async Task OnEmptyRecycleBinAsync() + private Task OnEmptyRecycleBinAsync() { + var domain = Assembly.GetExecutingAssembly().GetName().Name; + var result = MessageBox.Show( - string.Format(TranslationHelper.Get("ConfirmDeleteText", domain: Assembly.GetExecutingAssembly().GetName().Name), _info.TotalCount), - TranslationHelper.Get("RecycleBinButton", domain: Assembly.GetExecutingAssembly().GetName().Name), + string.Format(TranslationHelper.Get("ConfirmDeleteText", domain: domain), _info.TotalCount), + TranslationHelper.Get("RecycleBinButton", domain: domain), MessageBoxButton.YesNo, MessageBoxImage.Warning); - if (result == MessageBoxResult.Yes) + if (result != MessageBoxResult.Yes) + return Task.CompletedTask; + + // The emptying itself runs in a detached QuickLook.exe helper process, so it keeps + // running even when this viewer window is closed or another file gets previewed. + // This panel only reflects the state; it must never wait for the deletion itself. + emptyButton.IsEnabled = false; + totalSizeAndCount.Text = TranslationHelper.Get( + "RecycleBinEmptyingText", + domain: domain, + failsafe: "Emptying the Recycle Bin in the background..."); + + Process helperProcess; + try { - await Task.Run(() => + helperProcess = Process.Start(new ProcessStartInfo { - if (RecycleBinHelper.EmptyRecycleBin()) - { - UpdateState(); - } + FileName = Process.GetCurrentProcess().MainModule.FileName, + Arguments = "--empty-recycle-bin", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory, }); + + if (helperProcess == null) + throw new InvalidOperationException("The helper process could not be started."); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + + MessageBox.Show( + "QuickLook could not start the background process to empty the Recycle Bin." + + Environment.NewLine + Environment.NewLine + ex.Message, + TranslationHelper.Get("RecycleBinButton", domain: domain), + MessageBoxButton.OK, MessageBoxImage.Error); + + emptyButton.IsEnabled = true; + UpdateState(); + return Task.CompletedTask; } + + // Never block the UI thread. Waiting is only used to refresh the panel once the + // helper is done; the window (and this plugin instance) may be gone by then. + _ = Task.Run(() => + { + try + { + helperProcess.WaitForExit(); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + finally + { + try + { + helperProcess.Dispose(); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + + try + { + Dispatcher?.BeginInvoke(new Action(() => + { + try + { + UpdateState(); + emptyButton.IsEnabled = true; + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + })); + } + catch (Exception ex) + { + // The viewer window may have been closed while the helper was running. + Debug.WriteLine(ex); + } + }); + + return Task.CompletedTask; } private void UpdateState() @@ -169,23 +250,12 @@ public struct SHQUERYRBINFO [DllImport("shell32.dll", CharSet = CharSet.Unicode)] private static extern int SHQueryRecycleBin(string pszRootPath, ref SHQUERYRBINFO pSHQueryRBInfo); - [DllImport("shell32.dll", CharSet = CharSet.Unicode)] - private static extern int SHEmptyRecycleBin(nint hwnd, string pszRootPath, RecycleFlags dwFlags); - [DllImport("shell32.dll", CharSet = CharSet.Auto)] private static extern int ExtractIconEx(string lpszFile, int nIconIndex, nint[] phiconLarge, nint[] phiconSmall, int nIcons); [DllImport("user32.dll", SetLastError = true)] private static extern bool DestroyIcon(nint hIcon); - [Flags] - public enum RecycleFlags : uint - { - SHERB_NOCONFIRMATION = 0x00000001, - SHERB_NOPROGRESSUI = 0x00000002, - SHERB_NOSOUND = 0x00000004, - } - public static RecycleBinInfo GetRecycleBinInfo() { var info = new SHQUERYRBINFO() @@ -237,13 +307,6 @@ public static RecycleBinInfo GetRecycleBinInfo() return default; } - public static bool EmptyRecycleBin(RecycleFlags flags = RecycleFlags.SHERB_NOSOUND | RecycleFlags.SHERB_NOCONFIRMATION | RecycleFlags.SHERB_NOPROGRESSUI) - { - int result = SHEmptyRecycleBin(IntPtr.Zero, null, flags); - - return result == 0; // S_OK (0) - } - private static string[] GetIcons() { const string keyPath = @"CLSID\{645FF040-5081-101B-9F08-00AA002F954E}\DefaultIcon"; diff --git a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config index 6bb8817de..41d96e5e4 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config +++ b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config @@ -6,173 +6,202 @@ إفراغ سلة المحذوفات هل أنت متأكد أنك تريد حذف هذه العناصر {0} نهائيًا؟ الحجم الكلي {0}، عدد العناصر {1} + يتم إفراغ سلة المحذوفات في الخلفية… La paperera està buida Buida la paperera Segur que vols eliminar definitivament aquests {0} elements? Mida total {0}, {1} elements + S'està buidant la paperera en segon pla… Ο κάδος ανακύκλωσης είναι άδειος Άδειασμα κάδου ανακύκλωσης Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτά τα {0} στοιχεία; Συνολικό μέγεθος {0}, {1} στοιχεία + Άδειασμα του κάδου ανακύκλωσης στο παρασκήνιο… סל המחזור ריק רוקן את סל המחזור האם אתה בטוח שברצונך למחוק לצמיתות את {0} פריטים אלה? גודל כולל {0}, {1} פריטים + ריקון סל המחזור ברקע… रीसायकल बिन खाली है रीसायकल बिन खाली करें क्या आप वाकई इन {0} आइटम्स को स्थायी रूप से हटाना चाहते हैं? कुल आकार {0}, {1} आइटम्स + रीसायकल बिन पृष्ठभूमि में खाली किया जा रहा है… A lomtár üres Lomtár ürítése Biztosan törölni szeretné ezt a(z) {0} elemet véglegesen? Teljes méret {0}, {1} elem + A lomtár ürítése a háttérben… Tempat Sampah kosong Kosongkan Tempat Sampah Apakah Anda yakin ingin menghapus {0} item ini secara permanen? Ukuran total {0}, {1} item + Mengosongkan Tempat Sampah di latar belakang… Il cestino è vuoto Svuota cestino Sei sicuro di voler eliminare definitivamente questi {0} elementi? Dimensione totale {0}, {1} elementi + Svuotamento del cestino in background… पुनःप्राप्ती बिन रिकामी आहे पुनःप्राप्ती बिन रिकामा करा आपण खरोखरच ही {0} आयटम्स कायमचे हटवू इच्छिता? एकूण आकार {0}, {1} आयटम्स + पुनःप्राप्ती बिन पार्श्वभूमीत रिकामी होत आहे… Papirkurven er tom Tøm papirkurven Er du sikker på at du vil slette disse {0} elementene permanent? Total størrelse {0}, {1} elementer + Tømmer papirkurven i bakgrunnen… Prullenbak is leeg Prullenbak legen Weet je zeker dat je deze {0} items permanent wilt verwijderen? Totale grootte {0}, {1} items + Prullenbak wordt op de achtergrond geleegd… Kosz jest pusty Opróżnij kosz Czy na pewno chcesz trwale usunąć te {0} elementy? Całkowity rozmiar {0}, {1} elementów + Opróżnianie kosza w tle… Lixeira está vazia Esvaziar lixeira Tem certeza de que deseja excluir permanentemente estes {0} itens? Tamanho total {0}, {1} itens + Esvaziando a Lixeira em segundo plano… A reciclagem está vazia Esvaziar reciclagem Tem a certeza que pretende eliminar permanentemente estes {0} itens? Tamanho total {0}, {1} itens + A esvaziar a Reciclagem em segundo plano… Корзина пуста Очистить корзину Вы уверены, что хотите навсегда удалить эти {0} элементов? Общий размер {0}, {1} элементов + Очистка корзины в фоновом режиме… Kôš je prázdny Vyprázdniť kôš Ste si istý, že chcete natrvalo odstrániť týchto {0} položiek? Celková veľkosť {0}, {1} položiek + Kôš sa vyprázdňuje na pozadí… Geri Dönüşüm Kutusu boş Geri Dönüşüm Kutusunu Boşalt Bu {0} öğeyi kalıcı olarak silmek istediğinizden emin misiniz? Toplam boyut {0}, {1} öğe + Geri Dönüşüm Kutusu arka planda boşaltılıyor… Кошик порожній Очистити кошик Ви впевнені, що хочете назавжди видалити ці {0} елементів? Загальний розмір {0}, {1} елементів + Очищення кошика у фоновому режимі… Thùng rác trống Làm trống thùng rác Bạn có chắc muốn xóa vĩnh viễn {0} mục này không? Tổng kích thước {0}, {1} mục + Đang dọn Thùng rác trong nền… Recycle Bin is empty Empty Recycle Bin Are you sure you want to permanently delete these {0} item(s)? Total size {0}, {1} items + Emptying the Recycle Bin in the background… 휴지통이 비어 있습니다 휴지통 비우기 {0}개 항목을 영구적으로 삭제하시겠습니까? 전체 크기 {0}, {1}개 항목 + 휴지통을 백그라운드에서 비우는 중… 回收站为空 清空回收站 您确定要永久删除这 {0} 个项目吗? 总大小 {0},共 {1} 个项目 + 正在后台清空回收站… 回收站是空的 清空回收站 您確定要永久刪除這 {0} 個項目嗎? 總大小 {0},共 {1} 個項目 + 正在背景清空資源回收筒… ごみ箱は空です ごみ箱を空にする これらの {0} 項目を完全に削除してもよろしいですか? 合計サイズ {0}、{1} 項目 + ごみ箱をバックグラウンドで空にしています… La corbeille est vide Vider la corbeille Êtes-vous sûr de vouloir supprimer définitivement ces {0} élément(s) ? Taille totale {0}, {1} éléments + Vidage de la corbeille en arrière-plan… Der Papierkorb ist leer Papierkorb leeren Möchten Sie diese {0} Elemente wirklich dauerhaft löschen? Gesamtgröße {0}, {1} Elemente + Papierkorb wird im Hintergrund geleert… La papelera está vacía Vaciar papelera ¿Está seguro de que desea eliminar permanentemente estos {0} elemento(s)? Tamaño total {0}, {1} elementos + Vaciando la papelera en segundo plano… Papperskorgen är tom Töm papperskorgen Vill du ta bort de här {0} objekten permanent? Total storlek: {0} ({1} objekt) + Tömmer papperskorgen i bakgrunden… Coșul de reciclare este gol Golire Coș de reciclare Sunteți sigur că doriți să ștergeți definitiv aceste {0} elemente? Dimensiune totală {0}, {1} elemente + Se golește coșul de reciclare în fundal… diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs index 838e774e7..143c30a7e 100644 --- a/QuickLook/App.xaml.cs +++ b/QuickLook/App.xaml.cs @@ -133,6 +133,27 @@ static IEnumerable FetchFiles(string rootPath, string targetFileName) protected override void OnStartup(StartupEventArgs e) { + // Hidden background mode: empty the Recycle Bin in a detached process, so the + // deletion keeps running after the viewer window is closed, after another file + // gets previewed, or after Space is pressed again. + // It must be handled before any instance check, tray icon or window creation. + if (e.Args.Any(arg => string.Equals(arg, "--empty-recycle-bin", StringComparison.OrdinalIgnoreCase))) + { + bool emptied = RecycleBinCleaner.EmptyRecycleBin(out int hresult); + + try + { + ProcessHelper.WriteLog($"[RecycleBinCleaner] empty result={emptied} hresult=0x{hresult:X8}"); + } + catch + { + // Logging must never change the helper's outcome. + } + + Environment.Exit(emptied ? 0 : 1); + return; + } + if (!EnsureOSVersion() || !EnsureFirstInstance(e.Args) || !EnsureFolderWritable(SettingHelper.LocalDataPath)) diff --git a/QuickLook/Helpers/RecycleBinCleaner.cs b/QuickLook/Helpers/RecycleBinCleaner.cs new file mode 100644 index 000000000..f6d78e6e9 --- /dev/null +++ b/QuickLook/Helpers/RecycleBinCleaner.cs @@ -0,0 +1,185 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; + +namespace QuickLook.Helpers; + +/// +/// The actual Recycle Bin emptying routine used by the hidden `--empty-recycle-bin` +/// command line mode of QuickLook.exe. The plugin launches that mode as a detached +/// process, so emptying keeps running independently of the viewer window and of +/// whether another file gets previewed. +/// +public static class RecycleBinCleaner +{ + /// + /// Cross-process mutex which prevents two helper processes (for example, one + /// started by a movie-preview instance and one by the main instance) from + /// running a concurrent empty operation. + /// + public const string EmptyingMutexName = @"QuickLook.RecycleBin.Emptying"; + + [Flags] + private enum RecycleBinFlags : uint + { + SHERB_NOCONFIRMATION = 0x00000001, + SHERB_NOPROGRESSUI = 0x00000002, + SHERB_NOSOUND = 0x00000004, + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct SHQUERYRBINFO + { + public uint cbSize; + public ulong i64Size; + public ulong i64NumItems; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHEmptyRecycleBin(IntPtr hwnd, string pszRootPath, RecycleBinFlags dwFlags); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHQueryRecycleBin(string pszRootPath, ref SHQUERYRBINFO pSHQueryRBInfo); + + /// + /// Empties all Recycle Bin locations without any user interface. + /// + /// + /// true when the Recycle Bin was emptied (or another helper process was already + /// doing it); false when Windows reported a failure. + /// + public static bool EmptyRecycleBin() + { + return EmptyRecycleBin(out _); + } + + /// + /// Empties all Recycle Bin locations without any user interface. + /// + /// The HRESULT returned by SHEmptyRecycleBin (0 on success). + /// + /// true when the Recycle Bin was emptied (or another helper process was already + /// doing it); false when Windows reported a failure. + /// + public static bool EmptyRecycleBin(out int hresult) + { + hresult = 0; + + Mutex mutex = null; + var ownsMutex = false; + + try + { + mutex = new Mutex(false, EmptyingMutexName); + + try + { + // Do not wait: a second helper should exit immediately instead of + // starting a concurrent empty operation. + ownsMutex = mutex.WaitOne(0); + } + catch (AbandonedMutexException) + { + // The previous helper terminated while emptying. Take over. + ownsMutex = true; + } + + if (!ownsMutex) + { + // Another helper is already emptying the Recycle Bin. + return true; + } + + // SHEmptyRecycleBin returns E_UNEXPECTED (0x8000FFFF) when the Recycle Bin is + // already empty; that is not a failure for our background job. + if (TryGetRecycleBinInfo(out _, out var itemCount) && itemCount == 0) + { + hresult = 0; + return true; + } + + hresult = SHEmptyRecycleBin( + IntPtr.Zero, + null, + RecycleBinFlags.SHERB_NOCONFIRMATION | + RecycleBinFlags.SHERB_NOPROGRESSUI | + RecycleBinFlags.SHERB_NOSOUND); + + // S_FALSE (1) means "nothing was deleted"; both results are a success for us. + return hresult == 0 || hresult == 1; + } + catch (Exception ex) + { + Debug.WriteLine(ex); + hresult = Marshal.GetHRForException(ex); + return false; + } + finally + { + if (ownsMutex) + { + try + { + mutex.ReleaseMutex(); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + + mutex?.Dispose(); + } + } + + /// + /// Queries the current Recycle Bin size and item count. + /// + /// Total size in bytes. + /// Number of items. + /// true when the query succeeded. + public static bool TryGetRecycleBinInfo(out ulong totalSize, out ulong totalCount) + { + totalSize = 0; + totalCount = 0; + + try + { + var info = new SHQUERYRBINFO + { + cbSize = (uint)Marshal.SizeOf(typeof(SHQUERYRBINFO)), + }; + + if (SHQueryRecycleBin(null, ref info) != 0) // S_OK (0) + return false; + + totalSize = info.i64Size; + totalCount = info.i64NumItems; + + return true; + } + catch (Exception ex) + { + Debug.WriteLine(ex); + return false; + } + } +} From 9c5adc1c5681d7e79e2de1fb0af523df8f693c66 Mon Sep 17 00:00:00 2001 From: mohamadsm313m-prog Date: Fri, 13 Nov 2026 17:00:16 +0330 Subject: [PATCH 2/6] Preview shell-only items (MTP phones, zip folders) and harden the info panel Explorer parsing names of portable-device items are not file system paths: System.IO throws "Illegal characters in path" and SHCreateItemFromParsingName cannot resolve them on every Windows build. InfoPanel and WindowsThumbnailProvider now read shell properties and never throw, ShellItemHelper locates such items by walking the This PC namespace and materializes files into a temp file for the regular plugins (folders are never copied: their size is computed from shell metadata). ViewWindowManager routes items that only the info panel can show without copying anything. --- QuickLook/Helpers/ShellItemHelper.cs | 1326 +++++++++++++++++ QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs | 387 ++++- QuickLook/Plugin/InfoPanel/Plugin.cs | 4 +- QuickLook/Plugin/InfoPanel/ShellItemInfo.cs | 393 +++++ .../InfoPanel/WindowsThumbnailProvider.cs | 83 +- QuickLook/Translations.config | 1 + QuickLook/ViewWindowManager.cs | 115 +- 7 files changed, 2228 insertions(+), 81 deletions(-) create mode 100644 QuickLook/Helpers/ShellItemHelper.cs create mode 100644 QuickLook/Plugin/InfoPanel/ShellItemInfo.cs diff --git a/QuickLook/Helpers/ShellItemHelper.cs b/QuickLook/Helpers/ShellItemHelper.cs new file mode 100644 index 000000000..0c7a45604 --- /dev/null +++ b/QuickLook/Helpers/ShellItemHelper.cs @@ -0,0 +1,1326 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using QuickLook.Plugin.InfoPanel; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace QuickLook.Helpers; + +/// +/// Best-effort support for shell-only items, i.e. items that Explorer shows but that are not +/// accessible through the Win32 file system: files on an MTP device (a phone connected by USB), +/// items inside a zip folder, items of a virtual shell folder, ... +/// +/// +/// For such items and return false and +/// System.IO throws ("Illegal characters in path") because the +/// shell parsing name contains ':' and '?' characters. The only way to get the real content is to +/// let the shell copy the item into a temporary folder through IFileOperation. +/// Every method here is defensive: it never throws, failures simply return false/null. +/// +public static class ShellItemHelper +{ + /// Items larger than this are not copied to a temporary file. + public const long MaxMaterializedSize = 512L * 1024 * 1024; + + private const string TempRootFolderName = "QuickLook.ShellItems"; + + private static readonly string TempRoot = Path.Combine(Path.GetTempPath(), TempRootFolderName); + private static readonly object SyncRoot = new object(); + private static readonly HashSet ActiveTempFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + private static bool _staleCleanupDone; + + private static readonly HashSet ReservedFileNames = new HashSet( + new[] + { + "CON", "PRN", "AUX", "NUL", "CLOCK$", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }, StringComparer.OrdinalIgnoreCase); + + /// + /// True when is not a Win32 file system path but the shell can still + /// resolve it to an item (MTP device item, item inside a zip folder, ...). + /// + public static bool IsShellOnly(string path) + { + try + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + if (File.Exists(path) || Directory.Exists(path)) + return false; + + if (ShellItemInfo.IsShellItem(path)) + return true; + + // Items of an MTP/portable device are addressed with "::{...}" descriptions that + // SHCreateItemFromParsingName cannot always resolve on its own; the shell still knows them + // through the Explorer selection (see TryMaterializeViaExplorerSelection). + return path.StartsWith("::", StringComparison.Ordinal); + } + catch (Exception) + { + return false; + } + } + + /// + /// Copies a shell-only file item into a unique temporary folder and returns the local file path, + /// ready to be previewed by a normal file based plugin. Returns null when the item cannot (or + /// should not) be materialized: non shell paths, folders, CLSID roots handled by CLSIDViewer, + /// items larger than and any shell failure. + /// + public static string TryMaterialize(string path) + { + if (!IsShellOnly(path)) + return null; + + // Recycle Bin / This PC / ... are only addressed by an exact "::{GUID}" and are handled by CLSIDViewer. + if (IsExactClsidPath(path)) + return null; + + // 1) Preferred: ask the shell item directly to copy itself (works for items inside zip folders and + // for shell namespaces that SHCreateItemFromParsingName can resolve). + var materialized = TryMaterializeViaShellItem(path); + + if (!string.IsNullOrEmpty(materialized)) + return materialized; + + // 2) Portable devices (MTP phones) cannot be resolved from their parsing name on every Windows + // build, but the shell namespace below "This PC" knows them. Walk the namespace along the + // path segments, then copy the matching item. This is the main path for phone files. + materialized = TryMaterializeViaShellNamespace(path); + + if (!string.IsNullOrEmpty(materialized)) + return materialized; + + // 3) Last fallback: the item is the current Explorer selection; ask the shell to copy it. + return TryMaterializeViaExplorerSelection(path); + } + + /// + /// Resolves an MTP/portable-device item without relying on SHCreateItemFromParsingName: the item is + /// located by walking the "This PC" shell namespace along the segments of its absolute parsing name, + /// then copied to a temporary file. Returns null when the walk or the copy fails. + /// + private static string TryMaterializeViaShellNamespace(string path) + { + var tempDirectory = null as string; + + try + { + dynamic targetItem; + dynamic parentFolder; + + if (!TryWalkToItem(path, out targetItem, out parentFolder)) + return null; + + try + { + // A folder must never be copied to a temporary place: the whole content would be + // transferred just to show a preview. Folders are handled by the info panel instead. + if ((bool)targetItem.IsFolder) + return null; + + var safeName = MakeSafeFileName((string)targetItem.Name); + + if (string.IsNullOrEmpty(safeName)) + safeName = "shellitem"; + + var shellType = Type.GetTypeFromProgID("Shell.Application"); + + if (shellType == null) + return null; + + dynamic shell = Activator.CreateInstance(shellType); + + Directory.CreateDirectory(TempRoot); + + tempDirectory = Path.Combine(TempRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + dynamic destination = shell.NameSpace(tempDirectory); + + if (destination == null) + { + TryDeleteDirectory(tempDirectory); + return null; + } + + destination.CopyHere(targetItem, 0x14); // FOF_SILENT | FOF_NOCONFIRMATION + + string destinationFile; + + if (!WaitForCopyToFinish(tempDirectory, out destinationFile)) + { + TryDeleteDirectory(tempDirectory); + return null; + } + + lock (SyncRoot) + ActiveTempFiles.Add(destinationFile); + + return destinationFile; + } + finally + { + ReleaseComObject(targetItem); + ReleaseComObject(parentFolder); + } + } + catch (Exception) + { + if (!string.IsNullOrEmpty(tempDirectory)) + TryDeleteDirectory(tempDirectory); + + return null; + } + } + + /// + /// Walks the "This PC" shell namespace along the segments of an absolute parsing name and returns + /// the matching item together with the Folder object that contains it. This is how items of an + /// MTP/portable device are located without SHCreateItemFromParsingName. The caller owns the + /// returned COM objects. + /// + private static bool TryWalkToItem(string path, out dynamic item, out dynamic parentFolder) + { + item = null; + parentFolder = null; + + try + { + var shellType = Type.GetTypeFromProgID("Shell.Application"); + + if (shellType == null) + return false; + + dynamic shell = Activator.CreateInstance(shellType); + dynamic thisPc = shell.NameSpace(17); // ssfDRIVES (This PC) + + if (thisPc == null) + return false; + + dynamic deviceItem = FindPrefixItem(thisPc, path); + + if (deviceItem == null || !(bool)deviceItem.IsFolder) + return false; + + dynamic currentFolder = deviceItem.GetFolder; + string currentPath = deviceItem.Path; + + if (currentFolder == null) + return false; + + var remaining = path.Substring(currentPath.Length).TrimStart('\\'); + var segments = remaining.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length == 0) + return false; + + for (var i = 0; i < segments.Length; i++) + { + string wantedPath = currentPath.TrimEnd('\\') + "\\" + segments[i]; + dynamic found = FindExactItem(currentFolder, wantedPath); + + if (found == null) + return false; + + if (i == segments.Length - 1) + { + item = found; + parentFolder = currentFolder; + return true; + } + + currentFolder = found.GetFolder; + currentPath = wantedPath; + + if (currentFolder == null) + return false; + } + } + catch (Exception) + { + // ignored + } + + return false; + } + + /// + /// Reads the basic shell metadata of a shell-only item (MTP/portable device item, ...): real + /// display name, whether it is a folder, size in bytes (files only, -1 when unknown), last + /// modified date and the type description. Returns false when the item cannot be located. + /// + public static bool TryGetShellItemMetadata(string path, + out string name, out bool isFolder, out long size, out DateTime? modified, out string typeText) + { + name = null; + isFolder = false; + size = -1; + modified = null; + typeText = null; + + dynamic item; + dynamic parentFolder; + + try + { + if (!TryWalkToItem(path, out item, out parentFolder)) + return false; + + try + { + name = (string)item.Name; + isFolder = (bool)item.IsFolder; + typeText = (string)GetDetailsString(parentFolder, item, 1); + + var sizeText = (string)GetDetailsString(parentFolder, item, 2); + size = ParseHumanSize(sizeText); + + var modifiedText = (string)GetDetailsString(parentFolder, item, 3); + + if (!string.IsNullOrEmpty(modifiedText) + && DateTime.TryParse(modifiedText, CultureInfo.CurrentCulture, DateTimeStyles.None, out var parsed)) + { + modified = parsed; + } + + return !string.IsNullOrEmpty(name); + } + finally + { + ReleaseComObject(item); + ReleaseComObject(parentFolder); + } + } + catch (Exception) + { + return false; + } + } + + /// + /// Computes the total size and content count of a shell-only folder (e.g. a folder on an MTP + /// device) by enumerating the shell namespace. Cancellable through + /// and never throws. Folders are not copied for this; only their metadata is read. + /// + public static bool TryGetShellFolderSize(string path, Func shouldCancel, + out long totalSize, out int fileCount, out int folderCount, Action progress = null) + { + totalSize = 0; + fileCount = 0; + folderCount = 0; + + dynamic item; + dynamic parentFolder; + + try + { + if (!TryWalkToItem(path, out item, out parentFolder)) + return false; + + try + { + if (!(bool)item.IsFolder) + return false; + + dynamic folder = item.GetFolder; + + if (folder == null) + return false; + + return CountShellFolder(folder, shouldCancel, progress, 0, ref totalSize, ref fileCount, ref folderCount); + } + finally + { + ReleaseComObject(item); + ReleaseComObject(parentFolder); + } + } + catch (Exception) + { + return false; + } + } + + private static bool CountShellFolder(dynamic folder, Func shouldCancel, Action progress, int depth, + ref long totalSize, ref int fileCount, ref int folderCount) + { + if (depth > 32 || (shouldCancel != null && shouldCancel())) + return false; + + try + { + dynamic items = folder.Items(); + var count = (int)items.Count; + + for (var i = 0; i < count; i++) + { + if (shouldCancel != null && shouldCancel()) + return false; + + // Safety limit: never let a huge device folder block the preview forever. + if (fileCount + folderCount > 50000) + return false; + + dynamic child = null; + + try + { + child = items.Item(i); + + if ((bool)child.IsFolder) + { + folderCount++; + + dynamic subFolder = child.GetFolder; + + if (subFolder != null) + CountShellFolder(subFolder, shouldCancel, progress, depth + 1, ref totalSize, ref fileCount, ref folderCount); + } + else + { + fileCount++; + + var sizeText = (string)GetDetailsString(folder, child, 2); + var childSize = ParseHumanSize(sizeText); + + if (childSize > 0) + totalSize += childSize; + } + } + catch (Exception) + { + // Broken namespace item: skip it. + } + finally + { + ReleaseComObject(child); + } + + if (progress != null && (fileCount + folderCount) % 128 == 0) + { + try + { + progress(totalSize, fileCount, folderCount); + } + catch (Exception) + { + // Progress reporting must never break the calculation. + } + } + } + + return true; + } + catch (Exception) + { + return false; + } + } + + private static string GetDetailsString(dynamic folder, dynamic item, int column) + { + try + { + if (folder == null || item == null) + return null; + + return (string)folder.GetDetailsOf(item, column); + } + catch (Exception) + { + return null; + } + } + + /// + /// Converts an Explorer size column value ("182 MB", "4.09 KB", ...) into bytes. Returns -1 when + /// the value is empty or not a size. + /// + private static long ParseHumanSize(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return -1; + + try + { + var match = Regex.Match(text, @"([0-9][0-9.,\s]*)\s*([KMGTPE]?B|bytes?|[KMGTPE]|B)\b", + RegexOptions.IgnoreCase); + + if (!match.Success) + return -1; + + var numberText = match.Groups[1].Value.Trim().Replace(",", string.Empty); + var unit = match.Groups[2].Value.ToUpperInvariant(); + + if (!double.TryParse(numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + && !double.TryParse(numberText, NumberStyles.Float, CultureInfo.CurrentCulture, out value)) + { + return -1; + } + + var exponent = unit.StartsWith("K") ? 1 + : unit.StartsWith("M") ? 2 + : unit.StartsWith("G") ? 3 + : unit.StartsWith("T") ? 4 + : unit.StartsWith("P") ? 5 + : unit.StartsWith("E") ? 6 + : 0; + + if (unit.StartsWith("B") && unit.Length > 1) + exponent = 0; + + return (long)(value * Math.Pow(1024, exponent)); + } + catch (Exception) + { + return -1; + } + } + + private static dynamic FindPrefixItem(dynamic folder, string path) + { + try + { + dynamic items = folder.Items(); + var count = (int)items.Count; + + for (var i = 0; i < count; i++) + { + dynamic item = null; + + try + { + item = items.Item(i); + string itemPath = item.Path; + + if (string.IsNullOrEmpty(itemPath) || !(bool)item.IsFolder) + { + ReleaseComObject(item); + item = null; + continue; + } + + var endsWithSeparator = itemPath.EndsWith("\\", StringComparison.Ordinal) + || itemPath.EndsWith("/", StringComparison.Ordinal); + + if (path.StartsWith(itemPath, StringComparison.OrdinalIgnoreCase) + && (endsWithSeparator || (path.Length > itemPath.Length && path[itemPath.Length] == '\\')) + && path.Length > itemPath.Length) + { + return item; // caller owns the COM object + } + } + catch (Exception) + { + // Some namespace items throw when their path is queried; skip them. + } + + ReleaseComObject(item); + } + } + catch (Exception) + { + // ignored + } + + return null; + } + + private static dynamic FindExactItem(dynamic folder, string wantedPath) + { + try + { + dynamic items = folder.Items(); + var count = (int)items.Count; + + for (var i = 0; i < count; i++) + { + dynamic item = null; + + try + { + item = items.Item(i); + string itemPath = item.Path; + + if (string.Equals(itemPath, wantedPath, StringComparison.OrdinalIgnoreCase)) + return item; // caller owns the COM object + } + catch (Exception) + { + // ignore broken items + } + + ReleaseComObject(item); + } + } + catch (Exception) + { + // ignored + } + + return null; + } + + private static string TryMaterializeViaShellItem(string path) + { + var tempDirectory = null as string; + + try + { + if (ShellItemInfo.TryGetIsFolder(path, out var isFolder) && isFolder) + return null; + + if (ShellItemInfo.TryGetSize(path, out var size) && size > MaxMaterializedSize) + return null; + + CleanupStaleTempFilesOnce(); + + var safeName = MakeSafeFileName(ShellItemInfo.GetDisplayNameOrNull(path)); + + if (string.IsNullOrEmpty(safeName)) + safeName = MakeSafeFileName(GetNameFromRawPath(path)); + + if (string.IsNullOrEmpty(safeName)) + safeName = "shellitem"; + + if (Path.GetExtension(safeName).Length == 0) + { + var extension = GetExtensionFromRawPath(path); + + if (!string.IsNullOrEmpty(extension) && safeName.Length + extension.Length <= 120) + safeName += extension; + } + + Directory.CreateDirectory(TempRoot); + + tempDirectory = Path.Combine(TempRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var destinationFile = Path.Combine(tempDirectory, safeName); + + if (!CopyShellItem(path, tempDirectory, safeName)) + { + TryDeleteDirectory(tempDirectory); + return null; + } + + if (!File.Exists(destinationFile)) + { + // The shell may have changed the destination name; accept whatever single file landed in the folder. + var files = Directory.GetFiles(tempDirectory); + + if (files.Length == 0) + { + TryDeleteDirectory(tempDirectory); + return null; + } + + destinationFile = files[0]; + } + + lock (SyncRoot) + ActiveTempFiles.Add(destinationFile); + + return destinationFile; + } + catch (Exception) + { + if (!string.IsNullOrEmpty(tempDirectory)) + TryDeleteDirectory(tempDirectory); + + return null; + } + } + + + /// + /// Fallback for shell items whose parsing name cannot be resolved by SHCreateItemFromParsingName + /// (typically items of an MTP/portable device, e.g. a photo on a phone). When the item is the + /// current Explorer selection - which is exactly the QuickLook preview flow - Shell.Application + /// exposes the selected FolderItem and the shell can copy it into a temporary file. + /// + private static string TryMaterializeViaExplorerSelection(string path) + { + var tempDirectory = null as string; + + try + { + var shellType = Type.GetTypeFromProgID("Shell.Application"); + + if (shellType == null) + return null; + + dynamic shell = Activator.CreateInstance(shellType); + dynamic windows = shell.Windows(); + dynamic sourceItem = null; + + var windowCount = (int)windows.Count; + + for (var i = 0; i < windowCount && sourceItem == null; i++) + { + dynamic window = null; + + try + { + window = windows.Item(i); + + dynamic document = window.Document; + dynamic selectedItems = document.SelectedItems(); + + var itemCount = (int)selectedItems.Count; + + for (var j = 0; j < itemCount; j++) + { + dynamic candidate = selectedItems.Item(j); + string candidatePath = candidate.Path; + + if (string.Equals(candidatePath, path, StringComparison.OrdinalIgnoreCase)) + { + sourceItem = candidate; + break; + } + } + } + catch (Exception) + { + // Not a shell window (or the shell recycled it while enumerating). + } + finally + { + ReleaseComObject(window); + } + } + + if (sourceItem == null) + return null; + + // A folder must never be copied: it would recursively transfer all of its content. + if ((bool)sourceItem.IsFolder) + return null; + + string safeName = MakeSafeFileName((string)sourceItem.Name); + + if (string.IsNullOrEmpty(safeName)) + safeName = "shellitem"; + + Directory.CreateDirectory(TempRoot); + + tempDirectory = Path.Combine(TempRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + dynamic destination = shell.NameSpace(tempDirectory); + + if (destination == null) + { + TryDeleteDirectory(tempDirectory); + return null; + } + + const int flags = 0x14; // FOF_SILENT | FOF_NOCONFIRMATION + destination.CopyHere(sourceItem, flags); + + var destinationFile = Path.Combine(tempDirectory, safeName); + + if (!WaitForCopyToFinish(tempDirectory, out destinationFile)) + { + TryDeleteDirectory(tempDirectory); + return null; + } + + lock (SyncRoot) + ActiveTempFiles.Add(destinationFile); + + return destinationFile; + } + catch (Exception) + { + if (!string.IsNullOrEmpty(tempDirectory)) + TryDeleteDirectory(tempDirectory); + + return null; + } + } + + /// + /// Shell copy operations (Folder.CopyHere) are asynchronous; wait until the copied file exists and + /// its size stops growing. Bounded so a stuck device copy can never hang the viewer forever. + /// + private static bool WaitForCopyToFinish(string directory, out string resultFile) + { + resultFile = null; + + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + long lastSize = -1; + var stableSamples = 0; + + while (stopwatch.Elapsed < timeout) + { + string[] files; + + try + { + files = Directory.GetFiles(directory); + } + catch (Exception) + { + return false; + } + + if (files.Length > 0) + { + var candidate = files[0]; + long size = 0; + + try + { + size = new FileInfo(candidate).Length; + } + catch (Exception) + { + size = 0; + } + + if (size > 0 && size == lastSize) + { + stableSamples++; + + if (stableSamples >= 3) // ~750 ms of a stable size + { + resultFile = candidate; + return true; + } + } + else + { + stableSamples = 0; + } + + lastSize = size; + resultFile = candidate; + } + + Thread.Sleep(250); + } + + return !string.IsNullOrEmpty(resultFile) && File.Exists(resultFile); + } + + /// + /// Deletes a file produced by together with its temporary folder. + /// Only paths inside the QuickLook temporary root are touched; never throws. + /// + public static void TryDeleteTempFile(string tempFile) + { + if (string.IsNullOrEmpty(tempFile)) + return; + + try + { + lock (SyncRoot) + ActiveTempFiles.Remove(tempFile); + + string fullPath; + + try + { + fullPath = Path.GetFullPath(tempFile); + } + catch (Exception) + { + return; + } + + if (!IsUnderTempRoot(fullPath)) + return; + + if (File.Exists(fullPath)) + File.Delete(fullPath); + + var directory = Path.GetDirectoryName(fullPath); + + if (!string.IsNullOrEmpty(directory)) + TryDeleteDirectory(directory); + } + catch (Exception) + { + // ignored + } + } + + /// + /// Removes temporary copies left behind by a previous QuickLook session. Files that are still in + /// use by the current session are preserved; never throws. + /// + public static void CleanupStaleTempFiles() + { + lock (SyncRoot) + CleanupStaleTempFilesCore(); + } + + private static void CleanupStaleTempFilesOnce() + { + lock (SyncRoot) + { + if (_staleCleanupDone) + return; + + _staleCleanupDone = true; + CleanupStaleTempFilesCore(); + } + } + + private static void CleanupStaleTempFilesCore() + { + try + { + if (!Directory.Exists(TempRoot)) + return; + + var cutoff = DateTime.UtcNow - TimeSpan.FromDays(1); + + foreach (var directory in Directory.GetDirectories(TempRoot)) + { + try + { + var inUse = false; + var files = Directory.GetFiles(directory); + + if (files.Length == 0) + { + TryDeleteDirectory(directory); + continue; + } + + foreach (var file in files) + { + if (!ActiveTempFiles.Contains(file)) + continue; + + inUse = true; + break; + } + + if (inUse) + continue; + + if (Directory.GetLastWriteTimeUtc(directory) < cutoff) + TryDeleteDirectory(directory); + } + catch (Exception) + { + // ignored + } + } + } + catch (Exception) + { + // ignored + } + } + + private static bool CopyShellItem(string sourcePath, string destinationFolder, string copyName) + { + IShellItem sourceItem = null; + IShellItem destinationItem = null; + IFileOperation fileOperation = null; + + try + { + if (!TryCreateShellItem(sourcePath, out sourceItem)) + return false; + + if (!TryCreateShellItem(destinationFolder, out destinationItem)) + return false; + + if (!TryCreateFileOperation(out fileOperation)) + return false; + + const uint flags = FOF_SILENT | FOF_NOCONFIRMATION | FOF_NOCONFIRMMKDIR | FOF_NOERRORUI; + + if (fileOperation.SetOperationFlags(flags) != 0) + return false; + + // No owner window: the operation must be completely silent. + fileOperation.SetOwnerWindow(IntPtr.Zero); + + if (fileOperation.CopyItem(sourceItem, destinationItem, copyName, IntPtr.Zero) != 0) + return false; + + if (fileOperation.PerformOperations() != 0) + return false; + + fileOperation.GetAnyOperationsAborted(out var aborted); + return aborted == 0; + } + catch (Exception) + { + return false; + } + finally + { + ReleaseComObject(fileOperation); + ReleaseComObject(destinationItem); + ReleaseComObject(sourceItem); + } + } + + private static bool TryCreateShellItem(string path, out IShellItem shellItem) + { + shellItem = null; + + if (string.IsNullOrWhiteSpace(path)) + return false; + + try + { + var riid = new Guid(IID_IShellItem); + var hr = SHCreateItemFromParsingName(path, IntPtr.Zero, ref riid, out var item); + + if (hr < 0 || item == null) + return false; + + shellItem = item; + return true; + } + catch (Exception) + { + return false; + } + } + + private static bool TryCreateFileOperation(out IFileOperation fileOperation) + { + fileOperation = null; + + try + { + var clsid = new Guid(CLSID_FileOperation); + var riid = new Guid(IID_IFileOperation); + var hr = CoCreateInstance(ref clsid, IntPtr.Zero, ClsCtxAll, ref riid, out var operation); + + if (hr < 0 || operation == null) + return false; + + fileOperation = operation; + return true; + } + catch (Exception) + { + return false; + } + } + + private static string MakeSafeFileName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + var invalidChars = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(name.Length); + + foreach (var c in name) + { + builder.Append(Array.IndexOf(invalidChars, c) >= 0 ? '_' : c); + } + + var safe = builder.ToString().Trim().TrimEnd('.'); + + if (safe.Length == 0) + return null; + + var baseName = Path.GetFileNameWithoutExtension(safe); + + if (ReservedFileNames.Contains(baseName)) + safe = "_" + safe; + + if (safe.Length > 120) + { + var extension = Path.GetExtension(safe); + + if (extension.Length > 20) + extension = string.Empty; + + var baseLength = 120 - extension.Length; + + if (baseLength < 1) + baseLength = 1; + + safe = safe.Substring(0, baseLength) + extension; + } + + return safe; + } + + private static string GetNameFromRawPath(string path) + { + try + { + var name = Path.GetFileName(path); + + if (!string.IsNullOrEmpty(name)) + return name; + } + catch (Exception) + { + // System.IO rejects the parsing name, fall through to plain string handling. + } + + try + { + var trimmed = path.TrimEnd('\\', '/'); + var separator = Math.Max(trimmed.LastIndexOf('\\'), trimmed.LastIndexOf('/')); + var candidate = separator >= 0 ? trimmed.Substring(separator + 1) : trimmed; + + if (candidate.StartsWith("::", StringComparison.Ordinal)) + candidate = candidate.Substring(2); + + return candidate.IndexOf('.') > 0 ? candidate : null; + } + catch (Exception) + { + return null; + } + } + + private static string GetExtensionFromRawPath(string path) + { + try + { + var raw = path?.TrimEnd('\\', '/'); + + if (string.IsNullOrEmpty(raw)) + return string.Empty; + + var separator = Math.Max(raw.LastIndexOf('\\'), raw.LastIndexOf('/')); + var name = separator >= 0 ? raw.Substring(separator + 1) : raw; + var dot = name.LastIndexOf('.'); + + if (dot <= 0 || dot == name.Length - 1) + return string.Empty; + + var extension = name.Substring(dot); + + if (extension.Length > 20) + return string.Empty; + + foreach (var c in extension) + { + if (!char.IsLetterOrDigit(c) && c != '.' && c != '_' && c != '-') + return string.Empty; + } + + return extension; + } + catch (Exception) + { + return string.Empty; + } + } + + /// + /// True for paths whose whole meaning is one shell namespace root, e.g. "::{645FF040-5081-101B-9F08-00AA002F954E}" + /// (Recycle Bin) or "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" (This PC). Those are handled by CLSIDViewer. + /// + public static bool IsExactClsidPath(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + var trimmed = path.Trim(); + + if (!trimmed.StartsWith("::", StringComparison.Ordinal)) + return false; + + if (trimmed.IndexOf('\\') >= 0 || trimmed.IndexOf('/') >= 0) + return false; + + if (trimmed.Length < 5 || trimmed[2] != '{' || trimmed[trimmed.Length - 1] != '}') + return false; + + return Guid.TryParse(trimmed.Substring(3, trimmed.Length - 4), out _); + } + + private static bool IsUnderTempRoot(string fullPath) + { + var root = TempRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + return fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase) + || string.Equals(fullPath, TempRoot, StringComparison.OrdinalIgnoreCase); + } + + private static void TryDeleteDirectory(string directory) + { + try + { + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) + Directory.Delete(directory, true); + } + catch (Exception) + { + // ignored + } + } + + private static void ReleaseComObject(object comObject) + { + if (comObject == null) + return; + + try + { + if (Marshal.IsComObject(comObject)) + Marshal.ReleaseComObject(comObject); + } + catch (Exception) + { + // ignored + } + } + + private const string CLSID_FileOperation = "3AD05575-8857-4850-9277-11B85BDB8E09"; + private const string IID_IFileOperation = "947AAB5F-0A5C-4C13-B4D6-4BF7836FC9F8"; + private const string IID_IShellItem = "43826D1E-E718-42EE-BC55-A1E261C37BFE"; + + private const uint ClsCtxAll = 0x17; + + private const uint FOF_SILENT = 0x0004; + private const uint FOF_NOCONFIRMATION = 0x0010; + private const uint FOF_NOCONFIRMMKDIR = 0x0200; + private const uint FOF_NOERRORUI = 0x0400; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int SHCreateItemFromParsingName( + [MarshalAs(UnmanagedType.LPWStr)] string pszPath, + IntPtr pbc, + ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out IShellItem shellItem); + + [DllImport("ole32.dll")] + private static extern int CoCreateInstance( + ref Guid rclsid, + IntPtr pUnkOuter, + uint dwClsContext, + ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out IFileOperation ppv); + + [ComImport] + [Guid(IID_IShellItem)] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItem + { + [PreserveSig] + int BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv); + + [PreserveSig] + int GetParent(out IntPtr ppsi); + + [PreserveSig] + int GetDisplayName(uint sigdnName, out IntPtr ppszName); + + [PreserveSig] + int GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); + + [PreserveSig] + int Compare(IntPtr psi, uint hint, out int piOrder); + } + + [ComImport] + [Guid(IID_IFileOperation)] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IFileOperation + { + [PreserveSig] + int Advise(IntPtr pfops, out uint pdwCookie); + + [PreserveSig] + int Unadvise(uint dwCookie); + + [PreserveSig] + int SetOperationFlags(uint dwOperationFlags); + + [PreserveSig] + int SetProgressMessage([MarshalAs(UnmanagedType.LPWStr)] string pszMessage); + + [PreserveSig] + int SetProgressDialog(IntPtr popdlg); + + [PreserveSig] + int SetProperties(IntPtr pproparray); + + [PreserveSig] + int SetOwnerWindow(IntPtr hwndOwner); + + [PreserveSig] + int ApplyPropertiesToItem(IntPtr psiItem); + + [PreserveSig] + int ApplyPropertiesToItems(IntPtr punkItems); + + [PreserveSig] + int RenameItem(IntPtr psiItem, [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, IntPtr pfopsItem); + + [PreserveSig] + int RenameItems(IntPtr pUnkItems, [MarshalAs(UnmanagedType.LPWStr)] string pszNewName); + + [PreserveSig] + int MoveItem(IntPtr psiItem, IntPtr psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, IntPtr pfopsItem); + + [PreserveSig] + int MoveItems(IntPtr punkItems, IntPtr psiDestinationFolder); + + [PreserveSig] + int CopyItem(IShellItem psiItem, IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszCopyName, IntPtr pfopsItem); + + [PreserveSig] + int CopyItems(IntPtr punkItems, IntPtr psiDestinationFolder); + + [PreserveSig] + int DeleteItem(IntPtr psiItem, IntPtr pfopsItem); + + [PreserveSig] + int DeleteItems(IntPtr punkItems); + + [PreserveSig] + int NewItem(IntPtr psiDestinationFolder, uint dwFileAttributes, + [MarshalAs(UnmanagedType.LPWStr)] string pszName, + [MarshalAs(UnmanagedType.LPWStr)] string pszTemplateName, IntPtr pfopsItem); + + [PreserveSig] + int PerformOperations(); + + [PreserveSig] + int GetAnyOperationsAborted(out int pfAnyOperationsAborted); + } +} diff --git a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs index e4c1ce1dd..26461b7f7 100644 --- a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs +++ b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs @@ -17,6 +17,7 @@ using QuickLook.Common.ExtensionMethods; using QuickLook.Common.Helpers; +using QuickLook.Helpers; using System; using System.Globalization; using System.IO; @@ -27,6 +28,8 @@ namespace QuickLook.Plugin.InfoPanel; public partial class InfoPanel : UserControl { + private const string UnknownValue = "—"; + private bool _stop; public InfoPanel() @@ -41,82 +44,356 @@ public bool Stop } public void DisplayInfo(string path) + { + // DisplayInfo is called for *any* path Explorer hands over, including shell parsing names of + // MTP device items that System.IO rejects with "Illegal characters in path". This method must + // never throw, otherwise the whole preview fails; fall back to the shell property system instead. + try + { + DisplayThumbnail(path); + + filename.Text = GetSafeDisplayName(path); + + var lastModified = GetSafeLastModified(path); + modDate.Text = string.Format(TranslationHelper.Get("InfoPanel_LastModified"), + lastModified?.ToString(CultureInfo.CurrentCulture) ?? UnknownValue); + + Stop = false; + + _ = Task.Run(() => + { + try + { + UpdateSizeText(path); + } + catch (Exception e) + { + WriteLog(e); + } + }); + } + catch (Exception e) + { + WriteLog(e); + } + } + + private void DisplayThumbnail(string path) { _ = Task.Run(() => { - var scale = DisplayDeviceHelper.GetCurrentScaleFactor(); + try + { + var scale = DisplayDeviceHelper.GetCurrentScaleFactor(); - var icon = - WindowsThumbnailProvider.GetThumbnail(path, - (int)(128 * scale.Horizontal), - (int)(128 * scale.Vertical), - ThumbnailOptions.ScaleUp); + var icon = + WindowsThumbnailProvider.GetThumbnail(path, + (int)(128 * scale.Horizontal), + (int)(128 * scale.Vertical), + ThumbnailOptions.ScaleUp); - var source = icon?.ToBitmapSource(); - icon?.Dispose(); + var source = icon?.ToBitmapSource(); + icon?.Dispose(); - Dispatcher.BeginInvoke(new Action(() => image.Source = source)); + Dispatcher.BeginInvoke(new Action(() => image.Source = source)); + } + catch (Exception e) + { + WriteLog(e); + } }); + } - var name = Path.GetFileName(path); - filename.Text = string.IsNullOrEmpty(name) ? path : name; - - var last = File.GetLastWriteTime(path); - modDate.Text = string.Format(TranslationHelper.Get("InfoPanel_LastModified"), - last.ToString(CultureInfo.CurrentCulture)); + private void UpdateSizeText(string path) + { + if (string.IsNullOrEmpty(path)) + return; - Stop = false; + long? fileSize = null; - _ = Task.Run(() => + try { if (File.Exists(path)) { - var size = new FileInfo(path).Length; - - Dispatcher.Invoke(() => { totalSize.Text = size.ToPrettySize(2); }); + try + { + fileSize = new FileInfo(path).Length; + } + catch (Exception) + { + // ignored, fall back to the shell property system below + } } - else if (Path.GetPathRoot(path) == path) // is this a drive? + } + catch (Exception) + { + // System.IO rejects shell parsing names; keep fileSize null + } + + if (fileSize.HasValue) + { + var size = fileSize.Value; + + RunOnUiThread(() => { totalSize.Text = size.ToPrettySize(2); }); + return; + } + + var isDriveRoot = false; + + try + { + isDriveRoot = string.Equals(Path.GetPathRoot(path), path, StringComparison.OrdinalIgnoreCase); + } + catch (Exception) + { + // not a Win32 path + } + + if (isDriveRoot) // is this a drive? + { + FileHelper.GetDriveSpace(path, out var totalSpace, out var totalFreeSpace); + + RunOnUiThread(() => { - FileHelper.GetDriveSpace(path, out var totalSpace, out var totalFreeSpace); + totalSize.Text = + string.Format(TranslationHelper.Get("InfoPanel_DriveSize"), + totalSpace.ToPrettySize(2), + totalFreeSpace.ToPrettySize(2)); + }); + + return; + } + + if (Directory.Exists(path)) + { + FileHelper.CountFolder(path, ref _stop, + out var totalDirsL, out var totalFilesL, out var totalSizeL); - Dispatcher.Invoke(() => + if (!Stop) + RunOnUiThread(() => { + string t; + var folders = totalDirsL == 0 + ? string.Empty + : string.Format(TranslationHelper.Get( + totalDirsL == 1 ? "InfoPanel_Folder" : "InfoPanel_Folders"), totalDirsL); + var files = totalFilesL == 0 + ? string.Empty + : string.Format(TranslationHelper.Get( + totalFilesL == 1 ? "InfoPanel_File" : "InfoPanel_Files"), totalFilesL); + + if (!string.IsNullOrEmpty(folders) && !string.IsNullOrEmpty(files)) + t = string.Format( + TranslationHelper.Get("InfoPanel_FolderAndFile"), folders, files); + else if (string.IsNullOrEmpty(folders) && string.IsNullOrEmpty(files)) + t = string.Empty; + else + t = $"({folders}{files})"; + totalSize.Text = - string.Format(TranslationHelper.Get("InfoPanel_DriveSize"), - totalSpace.ToPrettySize(2), - totalFreeSpace.ToPrettySize(2)); + $"{totalSizeL.ToPrettySize(2)} {t}"; }); + + return; + } + + // Shell-only item (file on an MTP device, item inside a zip folder, ...). The basic metadata is + // read from the shell namespace, so nothing has to be copied to the local disk. + if (ShellItemHelper.TryGetShellItemMetadata(path, + out _, out var shellOnlyIsFolder, out var shellOnlySize, out _, out var shellOnlyType)) + { + if (shellOnlyIsFolder) + { + // A folder is never copied; its size is computed from the shell metadata in the background + // (this method already runs on a worker thread), so the name/date show up immediately. + RunOnUiThread(() => { totalSize.Text = TranslationHelper.Get("InfoPanel_CalculatingSize", failsafe: "Calculating size…"); }); + + if (ShellItemHelper.TryGetShellFolderSize(path, () => Stop, + out var folderSize, out var folderFiles, out var folderDirs, + (sizeSoFar, filesSoFar, dirsSoFar) => + { + if (!Stop) + { + RunOnUiThread(() => { totalSize.Text = + $"{TranslationHelper.Get("InfoPanel_CalculatingSize", failsafe: "Calculating size…")} " + + $"{sizeSoFar.ToPrettySize(1)} ({filesSoFar} files so far)"; }); + } + })) + { + if (!Stop) + RunOnUiThread(() => { totalSize.Text = FormatFolderSizeText(folderSize, folderDirs, folderFiles); }); + } + + return; } - else if (Directory.Exists(path)) + + if (shellOnlySize > 0) { - FileHelper.CountFolder(path, ref _stop, - out var totalDirsL, out var totalFilesL, out var totalSizeL); - - if (!Stop) - Dispatcher.Invoke(() => - { - string t; - var folders = totalDirsL == 0 - ? string.Empty - : string.Format(TranslationHelper.Get( - totalDirsL == 1 ? "InfoPanel_Folder" : "InfoPanel_Folders"), totalDirsL); - var files = totalFilesL == 0 - ? string.Empty - : string.Format(TranslationHelper.Get( - totalFilesL == 1 ? "InfoPanel_File" : "InfoPanel_Files"), totalFilesL); - - if (!string.IsNullOrEmpty(folders) && !string.IsNullOrEmpty(files)) - t = string.Format( - TranslationHelper.Get("InfoPanel_FolderAndFile"), folders, files); - else if (string.IsNullOrEmpty(folders) && string.IsNullOrEmpty(files)) - t = string.Empty; - else - t = $"({folders}{files})"; - - totalSize.Text = - $"{totalSizeL.ToPrettySize(2)} {t}"; - }); + RunOnUiThread(() => { totalSize.Text = shellOnlySize.ToPrettySize(2); }); + return; } - }); + + if (!string.IsNullOrEmpty(shellOnlyType)) + RunOnUiThread(() => { totalSize.Text = shellOnlyType; }); + + return; + } + + // A shell folder whose namespace walk failed (for example a folder inside a zip archive): at + // least report its type instead of leaving the default "Calculating size..." forever. + try + { + if (ShellItemInfo.TryGetIsFolder(path, out var parsedIsFolder) && parsedIsFolder) + { + var parsedFolderType = ShellItemInfo.TryGetTypeText(path, out var parsedType) && !string.IsNullOrEmpty(parsedType) + ? parsedType + : "File folder"; + + RunOnUiThread(() => { totalSize.Text = parsedFolderType; }); + return; + } + } + catch (Exception) + { + // ignored + } + + if (ShellItemInfo.TryGetSize(path, out var shellSize)) + { + RunOnUiThread(() => { totalSize.Text = shellSize.ToPrettySize(2); }); + return; + } + + if (ShellItemInfo.TryGetTypeText(path, out var typeText) && !string.IsNullOrEmpty(typeText)) + RunOnUiThread(() => { totalSize.Text = typeText; }); + } + + /// + /// Formats a folder size the same way the local folder branch of does. + /// + private static string FormatFolderSizeText(long totalSizeL, int totalDirsL, int totalFilesL) + { + var folders = totalDirsL == 0 + ? string.Empty + : string.Format(TranslationHelper.Get(totalDirsL == 1 ? "InfoPanel_Folder" : "InfoPanel_Folders"), totalDirsL); + var files = totalFilesL == 0 + ? string.Empty + : string.Format(TranslationHelper.Get(totalFilesL == 1 ? "InfoPanel_File" : "InfoPanel_Files"), totalFilesL); + + string t; + + if (!string.IsNullOrEmpty(folders) && !string.IsNullOrEmpty(files)) + t = string.Format(TranslationHelper.Get("InfoPanel_FolderAndFile"), folders, files); + else if (string.IsNullOrEmpty(folders) && string.IsNullOrEmpty(files)) + t = string.Empty; + else + t = $"({folders}{files})"; + + return $"{totalSizeL.ToPrettySize(2)} {t}"; + } + + internal static string GetSafeDisplayName(string path) + { + if (string.IsNullOrEmpty(path)) + return string.Empty; + + var isShellOnly = false; + + try + { + isShellOnly = !File.Exists(path) && !Directory.Exists(path); + } + catch (Exception) + { + isShellOnly = true; + } + + if (isShellOnly) + { + // Shell parsing names cannot be passed to System.IO and their last segment is an internal + // item id, not a name. Ask the shell namespace for the real display name first. + if (ShellItemHelper.TryGetShellItemMetadata(path, out var shellOnlyName, out _, out _, out _, out _) + && !string.IsNullOrEmpty(shellOnlyName)) + { + return shellOnlyName; + } + + if (ShellItemInfo.TryGetDisplayName(path, out var parsedShellName) && !string.IsNullOrEmpty(parsedShellName)) + return parsedShellName; + + return path; + } + + if (ShellItemInfo.TryGetDisplayName(path, out var shellName) && !string.IsNullOrEmpty(shellName)) + return shellName; + + try + { + var name = Path.GetFileName(path); + + if (!string.IsNullOrEmpty(name)) + return name; + } + catch (Exception) + { + // Illegal characters in path: give up and show the raw path. + } + + return path; + } + + private static DateTime? GetSafeLastModified(string path) + { + if (string.IsNullOrEmpty(path)) + return null; + + try + { + var lastWriteTime = File.GetLastWriteTime(path); + + // 1601-01-01 means "file not found" for System.IO. + if (lastWriteTime.Year > 1601) + return lastWriteTime; + } + catch (Exception) + { + // Illegal characters in path: use the shell property below. + } + + if (ShellItemInfo.TryGetDateModified(path, out var shellModified)) + return shellModified; + + // MTP/portable device items: SHCreateItemFromParsingName cannot read them, walk the shell namespace. + if (ShellItemHelper.TryGetShellItemMetadata(path, out _, out _, out _, out var shellOnlyModified, out _) + && shellOnlyModified.HasValue) + { + return shellOnlyModified; + } + + return null; + } + + private void RunOnUiThread(Action action) + { + try + { + Dispatcher.Invoke(action); + } + catch (Exception) + { + // The preview window is gone. + } + } + + private static void WriteLog(Exception e) + { + try + { + ProcessHelper.WriteLog(e.ToString()); + } + catch (Exception) + { + // ignored + } } } diff --git a/QuickLook/Plugin/InfoPanel/Plugin.cs b/QuickLook/Plugin/InfoPanel/Plugin.cs index 3be64e704..85e4c0ae3 100644 --- a/QuickLook/Plugin/InfoPanel/Plugin.cs +++ b/QuickLook/Plugin/InfoPanel/Plugin.cs @@ -39,7 +39,9 @@ public void Prepare(string path, ContextObject context) { context.PreferredSize = new Size { Width = 453, Height = 172 }; - context.Title = string.Empty; + // The window keeps the title of the previous preview when this stays empty, so use the real + // shell display name (works for MTP items too). + context.Title = InfoPanel.GetSafeDisplayName(path); context.TitlebarOverlap = false; context.TitlebarBlurVisibility = false; context.TitlebarColourVisibility = false; diff --git a/QuickLook/Plugin/InfoPanel/ShellItemInfo.cs b/QuickLook/Plugin/InfoPanel/ShellItemInfo.cs new file mode 100644 index 000000000..e572a1fe3 --- /dev/null +++ b/QuickLook/Plugin/InfoPanel/ShellItemInfo.cs @@ -0,0 +1,393 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Runtime.InteropServices; + +namespace QuickLook.Plugin.InfoPanel; + +/// +/// WPF-free helpers that read metadata of an arbitrary shell item. +/// +/// +/// The paths QuickLook receives from Explorer are not always Win32 file system paths. +/// Items on an MTP device (a phone connected via USB), items inside a zip folder or items of a +/// virtual shell folder are addressed by "shell parsing names" such as +/// ::{20D04FE0-3AEA-1069-A2D8-08002B30309D}\?\usb#vid_04e8&pid_6860#.... +/// System.IO APIs throw ("Illegal characters in path") for those, +/// so the shell property system is the only safe way to obtain their metadata. +/// Every method in this class is defensive: it never throws, failure is reported by the return value. +/// +public static class ShellItemInfo +{ + private const string IID_IShellItem2 = "7E9FB0D3-919F-4307-AB2E-9B1860310C93"; + + // PROPERTYKEY fmtid shared by the "System" property family (PKEY_Size, PKEY_DateModified, ...). + private static readonly Guid SystemPropertyFormatId = new Guid("B725F130-47EF-101A-A5F1-02608C9EEBAC"); + + private const uint PKEYItemTypeTextPid = 4; + private const uint PKEYSizePid = 12; + private const uint PKEYDateModifiedPid = 14; + + private const uint SFGAO_STREAM = 0x00400000; + private const uint SFGAO_FOLDER = 0x20000000; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int SHCreateItemFromParsingName( + [MarshalAs(UnmanagedType.LPWStr)] string pszPath, + IntPtr pbc, + ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out IShellItem2 shellItem); + + /// + /// Returns true when can be resolved to a shell item. + /// This includes ordinary file system paths and shell-only parsing names. + /// + public static bool IsShellItem(string path) + { + var item = CreateShellItem(path); + + if (item == null) + return false; + + ReleaseComObject(item); + return true; + } + + /// + /// Gets the name Explorer shows for the item (SIGDN_NORMALDISPLAY), which is the only usable + /// name source for shell-only items where System.IO.Path.GetFileName would throw. + /// + public static bool TryGetDisplayName(string path, out string displayName) + { + displayName = null; + + var item = CreateShellItem(path); + + if (item == null) + return false; + + var pszName = IntPtr.Zero; + + try + { + if (item.GetDisplayName(SIGDN.NORMALDISPLAY, out pszName) != 0 || pszName == IntPtr.Zero) + return false; + + var name = Marshal.PtrToStringUni(pszName); + + if (string.IsNullOrEmpty(name)) + return false; + + displayName = name; + return true; + } + catch (Exception) + { + return false; + } + finally + { + if (pszName != IntPtr.Zero) + Marshal.FreeCoTaskMem(pszName); + + ReleaseComObject(item); + } + } + + /// The display name, or null when it cannot be determined. + public static string GetDisplayNameOrNull(string path) + { + return TryGetDisplayName(path, out var displayName) ? displayName : null; + } + + /// + /// Gets the item size (PKEY_Size), which works both for file system items and for shell items + /// such as files on an MTP device. For folders the property may be 0 or missing. + /// + public static bool TryGetSize(string path, out long size) + { + size = 0L; + + var item = CreateShellItem(path); + + if (item == null) + return false; + + try + { + var key = CreateSystemPropertyKey(PKEYSizePid); + + if (item.GetUInt64(ref key, out var value) != 0) + return false; + + if (value > long.MaxValue) + return false; + + size = (long)value; + return true; + } + catch (Exception) + { + return false; + } + finally + { + ReleaseComObject(item); + } + } + + /// + /// Gets the item modification time (PKEY_DateModified) as local time. This is the fallback for + /// shell-only items where System.IO.File.GetLastWriteTime throws. + /// + public static bool TryGetDateModified(string path, out DateTime modified) + { + modified = DateTime.MinValue; + + var item = CreateShellItem(path); + + if (item == null) + return false; + + try + { + var key = CreateSystemPropertyKey(PKEYDateModifiedPid); + + if (item.GetFileTime(ref key, out var fileTime) != 0) + return false; + + var value = ((long)fileTime.dwHighDateTime << 32) | fileTime.dwLowDateTime; + + if (value <= 0) + return false; + + modified = DateTime.FromFileTimeUtc(value).ToLocalTime(); + return true; + } + catch (Exception) + { + return false; + } + finally + { + ReleaseComObject(item); + } + } + + /// Gets the item type description Explorer shows (PKEY_ItemTypeText), e.g. "JPEG image". + public static bool TryGetTypeText(string path, out string typeText) + { + typeText = null; + + var item = CreateShellItem(path); + + if (item == null) + return false; + + var pszType = IntPtr.Zero; + + try + { + var key = CreateSystemPropertyKey(PKEYItemTypeTextPid); + + if (item.GetString(ref key, out pszType) != 0 || pszType == IntPtr.Zero) + return false; + + var text = Marshal.PtrToStringUni(pszType); + + if (string.IsNullOrEmpty(text)) + return false; + + typeText = text; + return true; + } + catch (Exception) + { + return false; + } + finally + { + if (pszType != IntPtr.Zero) + Marshal.FreeCoTaskMem(pszType); + + ReleaseComObject(item); + } + } + + /// + /// Returns true when the item is a container (folder) rather than a file/stream. + /// Shell folders are not necessarily directories on a device, hence the SFGAO check. + /// + public static bool TryGetIsFolder(string path, out bool isFolder) + { + isFolder = false; + + var item = CreateShellItem(path); + + if (item == null) + return false; + + try + { + if (item.GetAttributes(SFGAO_FOLDER | SFGAO_STREAM, out var attributes) != 0) + return false; + + isFolder = (attributes & SFGAO_FOLDER) != 0 && (attributes & SFGAO_STREAM) == 0; + return true; + } + catch (Exception) + { + return false; + } + finally + { + ReleaseComObject(item); + } + } + + private static PROPERTYKEY CreateSystemPropertyKey(uint pid) + { + return new PROPERTYKEY(SystemPropertyFormatId, pid); + } + + private static IShellItem2 CreateShellItem(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + try + { + var riid = new Guid(IID_IShellItem2); + var hr = SHCreateItemFromParsingName(path, IntPtr.Zero, ref riid, out var shellItem); + + if (hr < 0 || shellItem == null) + return null; + + return shellItem; + } + catch (Exception) + { + // Badly formed parsing names make the shell API throw as well, not just fail. + return null; + } + } + + private static void ReleaseComObject(object comObject) + { + if (comObject == null) + return; + + try + { + if (Marshal.IsComObject(comObject)) + Marshal.ReleaseComObject(comObject); + } + catch (Exception) + { + // ignored + } + } + + private enum SIGDN : uint + { + NORMALDISPLAY = 0 + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct PROPERTYKEY + { + public Guid fmtid; + public uint pid; + + public PROPERTYKEY(Guid formatId, uint propertyId) + { + fmtid = formatId; + pid = propertyId; + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct FILETIME + { + public uint dwLowDateTime; + public uint dwHighDateTime; + } + + // Minimal IShellItem2 vtable (IUnknown + IShellItem + IShellItem2 methods in vtable order) up to + // the methods used above. The interface is declared with the IShellItem2 IID, so SHCreateItemFromParsingName + // returns the object behind it directly and no separate IShellItem query is required. + [ComImport] + [Guid(IID_IShellItem2)] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItem2 + { + [PreserveSig] + int BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv); + + [PreserveSig] + int GetParent(out IntPtr ppsi); + + [PreserveSig] + int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName); + + [PreserveSig] + int GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); + + [PreserveSig] + int Compare(IntPtr psi, uint hint, out int piOrder); + + [PreserveSig] + int GetPropertyStore(uint flags, ref Guid riid, out IntPtr ppv); + + [PreserveSig] + int GetPropertyStoreForKeys(IntPtr rgKeys, uint cKeys, uint flags, ref Guid riid, out IntPtr ppv); + + [PreserveSig] + int GetPropertyDescriptionList(ref PROPERTYKEY keyType, ref Guid riid, out IntPtr ppv); + + [PreserveSig] + int Update(IntPtr pbc); + + [PreserveSig] + int GetProperty(ref PROPERTYKEY key, IntPtr ppropvar); + + [PreserveSig] + int GetClsid(ref PROPERTYKEY key, out Guid pclsid); + + [PreserveSig] + int GetFileTime(ref PROPERTYKEY key, out FILETIME pft); + + [PreserveSig] + int GetInt32(ref PROPERTYKEY key, out int pi); + + [PreserveSig] + int GetString(ref PROPERTYKEY key, out IntPtr ppsz); + + [PreserveSig] + int GetUInt32(ref PROPERTYKEY key, out uint pui); + + [PreserveSig] + int GetUInt64(ref PROPERTYKEY key, out ulong pull); + + [PreserveSig] + int GetBool(ref PROPERTYKEY key, out int pf); + + [PreserveSig] + int GetIcon(ref PROPERTYKEY key, uint flags, out IntPtr phicon); + } +} diff --git a/QuickLook/Plugin/InfoPanel/WindowsThumbnailProvider.cs b/QuickLook/Plugin/InfoPanel/WindowsThumbnailProvider.cs index 7461d70c4..7ebf48180 100644 --- a/QuickLook/Plugin/InfoPanel/WindowsThumbnailProvider.cs +++ b/QuickLook/Plugin/InfoPanel/WindowsThumbnailProvider.cs @@ -54,20 +54,46 @@ private static extern int SHCreateItemFromParsingName( public static Bitmap GetThumbnail(string fileName, int width, int height, ThumbnailOptions options) { - var hBitmap = GetHBitmap(Path.GetFullPath(fileName), width, height, options); - - if (hBitmap == IntPtr.Zero) + // This method is called from a background task with any path Explorer can hand over. + // Path.GetFullPath throws ArgumentException ("Illegal characters in path") for shell parsing + // names of MTP device items, so fall back to the raw string: SHCreateItemFromParsingName + // understands those parsing names and can still produce a thumbnail. + if (string.IsNullOrEmpty(fileName)) return null; + var hBitmap = IntPtr.Zero; + try { + string parseName; + + try + { + parseName = Path.GetFullPath(fileName); + } + catch (Exception) + { + parseName = fileName; + } + + hBitmap = GetHBitmap(parseName, width, height, options); + + if (hBitmap == IntPtr.Zero) + return null; + // return a System.Drawing.Bitmap from the hBitmap return GetBitmapFromHBitmap(hBitmap); } + catch (Exception) + { + // Never let a thumbnail failure break the info panel. + return null; + } finally { - // delete HBitmap to avoid memory leaks - DeleteObject(hBitmap); + if (hBitmap != IntPtr.Zero) + // delete HBitmap to avoid memory leaks + DeleteObject(hBitmap); } } @@ -117,24 +143,45 @@ internal static Bitmap CreateAlphaBitmap(Bitmap srcBitmap, PixelFormat targetPix private static nint GetHBitmap(string fileName, int width, int height, ThumbnailOptions options) { - var shellItem2Guid = new Guid(IShellItem2Guid); - var retCode = - SHCreateItemFromParsingName(fileName, IntPtr.Zero, ref shellItem2Guid, out var nativeShellItem); + IShellItem nativeShellItem = null; - if (retCode != 0) - return IntPtr.Zero; - - var nativeSize = new NativeSize + try { - Width = width, - Height = height - }; + var shellItem2Guid = new Guid(IShellItem2Guid); + var retCode = + SHCreateItemFromParsingName(fileName, IntPtr.Zero, ref shellItem2Guid, out nativeShellItem); - var hr = ((IShellItemImageFactory)nativeShellItem).GetImage(nativeSize, options, out var hBitmap); + if (retCode != 0 || nativeShellItem == null) + return IntPtr.Zero; - Marshal.ReleaseComObject(nativeShellItem); + var nativeSize = new NativeSize + { + Width = width, + Height = height + }; - return hr == HResult.Ok ? hBitmap : IntPtr.Zero; + var hr = ((IShellItemImageFactory)nativeShellItem).GetImage(nativeSize, options, out var hBitmap); + + return hr == HResult.Ok ? hBitmap : IntPtr.Zero; + } + catch (Exception) + { + return IntPtr.Zero; + } + finally + { + if (nativeShellItem != null) + { + try + { + Marshal.ReleaseComObject(nativeShellItem); + } + catch (Exception) + { + // ignored + } + } + } } [ComImport] diff --git a/QuickLook/Translations.config b/QuickLook/Translations.config index 684efd329..714740627 100644 --- a/QuickLook/Translations.config +++ b/QuickLook/Translations.config @@ -265,6 +265,7 @@ {0} file {0} files ({0} and {1}) + Calculating size… Cancellation of "Prevent Closing" is not supported Copy as path Copy successfully diff --git a/QuickLook/ViewWindowManager.cs b/QuickLook/ViewWindowManager.cs index e18fc7004..bb6c94eb8 100644 --- a/QuickLook/ViewWindowManager.cs +++ b/QuickLook/ViewWindowManager.cs @@ -22,6 +22,7 @@ using System.Diagnostics; using System.IO; using System.Runtime.ExceptionServices; +using System.Threading.Tasks; namespace QuickLook; @@ -30,6 +31,8 @@ public class ViewWindowManager : IDisposable private static ViewWindowManager _instance; private string _invokedPath = string.Empty; + private string _previewPath = string.Empty; + private string _materializedTempFile; private ViewerWindow _viewerWindow; internal ViewWindowManager() @@ -151,22 +154,113 @@ public void InvokePreview(string path = null) if (_viewerWindow.IsVisible && path == _invokedPath) return; + var previewPath = path; + var useDefaultPlugin = false; + IViewer matchedPlugin = null; + var isDirectory = Directory.Exists(path); + if (!isDirectory && !File.Exists(path)) - if (!path.StartsWith("::")) // CLSID + { + if (!path.StartsWith("::", StringComparison.Ordinal)) // CLSID return; - // Check extension filtering before proceeding (skip for directories) - if (!isDirectory && !ExtensionFilterHelper.IsExtensionAllowed(path)) + // Shell parsing names of MTP items do not contain the file name (the last segment is an item + // id). A folder is shown by the info panel: its size is computed from shell metadata and + // nothing is copied. Files are copied to a temporary file so that the regular plugins (image, + // video, archive, text, ...) can preview them exactly like before. + var hasShellMetadata = ShellItemHelper.TryGetShellItemMetadata(path, + out _, out var shellItemIsFolder, out _, out _, out _); + + if (hasShellMetadata && shellItemIsFolder) + { + previewPath = path; + useDefaultPlugin = true; + } + else + { + var materializedPath = ShellItemHelper.TryMaterialize(path); + + if (!string.IsNullOrEmpty(materializedPath)) + { + // Extension filter settings must also protect previews coming from a device. + if (!ExtensionFilterHelper.IsExtensionAllowed(materializedPath)) + { + ShellItemHelper.TryDeleteTempFile(materializedPath); + return; + } + + // Some plugins (video, media info, ...) inspect the content instead of the extension, + // so the plugin match can only be decided on a real local file. When no plugin can + // preview it, the info panel shows the original shell item (real name/date/size); + // the temporary copy is then dropped so nothing unnecessary is kept on disk. + var matchedForCopy = PluginManager.GetInstance().FindMatch(materializedPath); + + if (matchedForCopy is Plugin.InfoPanel.Plugin) + { + ShellItemHelper.TryDeleteTempFile(materializedPath); + previewPath = path; + useDefaultPlugin = true; + } + else + { + matchedPlugin = matchedForCopy; + previewPath = materializedPath; + + // Delete the temporary copy when the preview window closes (or after a grace + // period when another preview replaces this one, see below). + _materializedTempFile = previewPath; + _viewerWindow.Closed += (sender, e) => + { + var tempFileToDelete = _materializedTempFile; + _materializedTempFile = null; + + if (!string.IsNullOrEmpty(tempFileToDelete)) + { + _ = Task.Delay(TimeSpan.FromSeconds(5)) + .ContinueWith(_ => ShellItemHelper.TryDeleteTempFile(tempFileToDelete), TaskScheduler.Default); + } + }; + } + } + else + { + // Exact "::{GUID}" namespace roots (Recycle Bin, This PC, ...) are handled by CLSIDViewer. + // Any other shell-only item that could not be copied (too large, access denied, ...) must + // never be handed to a file based plugin: it cannot open such a path. + previewPath = path; + useDefaultPlugin = !ShellItemHelper.IsExactClsidPath(path); + } + } + } + else if (!isDirectory && !ExtensionFilterHelper.IsExtensionAllowed(path)) + { + // Check extension filtering before proceeding (skip for directories) return; + } + + // A new preview replaces the previous one: drop the temporary copy of the replaced shell item. + // The previous plugin may still be finishing an asynchronous load of that file (image decoding, + // archive listing, ...), therefore remove it after a grace period instead of immediately. + var previousTempFile = _materializedTempFile; + + if (!string.IsNullOrEmpty(previousTempFile)) + { + _materializedTempFile = null; + _ = Task.Delay(TimeSpan.FromSeconds(20)) + .ContinueWith(_ => ShellItemHelper.TryDeleteTempFile(previousTempFile), TaskScheduler.Default); + } _invokedPath = path; + _previewPath = previewPath; RunFocusMonitor(); - var matchedPlugin = PluginManager.GetInstance().FindMatch(path); + matchedPlugin ??= useDefaultPlugin + ? PluginManager.GetInstance().DefaultPlugin + : PluginManager.GetInstance().FindMatch(previewPath); - BeginShowNewWindow(path, matchedPlugin); + BeginShowNewWindow(previewPath, matchedPlugin); } public void InvokePluginPreview(string plugin, string path = null) @@ -203,9 +297,16 @@ public void ReloadPreview() if (!_viewerWindow.IsVisible || string.IsNullOrEmpty(_invokedPath)) return; - var matchedPlugin = PluginManager.GetInstance().FindMatch(_invokedPath); + // For shell-only items the preview is shown from a materialized temporary file; prefer it while + // it still exists and fall back to the original path otherwise. + var previewPath = _previewPath; + + if (string.IsNullOrEmpty(previewPath) || (!File.Exists(previewPath) && !Directory.Exists(previewPath))) + previewPath = _invokedPath; + + var matchedPlugin = PluginManager.GetInstance().FindMatch(previewPath); - BeginShowNewWindow(_invokedPath, matchedPlugin); + BeginShowNewWindow(previewPath, matchedPlugin); } public void ToggleFullscreen() From 530acb7c2f06770efa13241065376dcdff55b2a2 Mon Sep 17 00:00:00 2001 From: mohamadsm313m-prog Date: Fri, 13 Nov 2026 18:02:50 +0330 Subject: [PATCH 3/6] Prepare shell-only previews without blocking the UI Copies of shell items (e.g. large files on an MTP phone) used to run synchronously on the WPF dispatcher, so the viewer froze until the transfer finished and the transfer could not be superseded. The preview window now appears immediately with the shell metadata and a progress status, while the copy runs on a cancellable STA worker; a newer preview or closing the window invalidates the pending work and cleans up its temporary file. Also addresses review findings: duplicate RecycleBin helper now waits for the active deletion, size limits are enforced in every copy strategy (folders are never copied and oversized items are rejected instead of retried), copy completion is verified against the expected size and a timeout is a failure (never a partial preview), recursive folder totals propagate incomplete results, localized size columns keep their decimal separator, S_FALSE attribute results are accepted, the IShellItem2 vtable order is corrected, non-:: shell names are resolved before being ignored, the temporary file lifecycle is tracked per preview, and each preview gets a fresh default-plugin instance. --- QuickLook/Helpers/RecycleBinCleaner.cs | 24 +- QuickLook/Helpers/ShellItemHelper.cs | 345 +++++++++++++++--- QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs | 24 +- QuickLook/Plugin/InfoPanel/ShellItemInfo.cs | 13 +- QuickLook/PreparingPreview.cs | 77 +++++ QuickLook/Translations.config | 1 + QuickLook/ViewWindowManager.cs | 346 ++++++++++++++----- 7 files changed, 695 insertions(+), 135 deletions(-) create mode 100644 QuickLook/PreparingPreview.cs diff --git a/QuickLook/Helpers/RecycleBinCleaner.cs b/QuickLook/Helpers/RecycleBinCleaner.cs index f6d78e6e9..ab20b0ee6 100644 --- a/QuickLook/Helpers/RecycleBinCleaner.cs +++ b/QuickLook/Helpers/RecycleBinCleaner.cs @@ -104,8 +104,28 @@ public static bool EmptyRecycleBin(out int hresult) if (!ownsMutex) { - // Another helper is already emptying the Recycle Bin. - return true; + // Another helper is already emptying the Recycle Bin. Wait for that operation to finish + // so this process does not report completion while the deletion is still running (the + // panel refreshes when this helper exits). + try + { + ownsMutex = mutex.WaitOne(TimeSpan.FromMinutes(10)); + } + catch (AbandonedMutexException) + { + // The other helper terminated while emptying, so the operation is over. + ownsMutex = true; + } + + if (!ownsMutex) + { + // The deletion is still running after the grace period: report a failure instead of + // claiming that the Recycle Bin is empty. + hresult = unchecked((int)0x80004005); // E_FAIL + return false; + } + + return TryGetRecycleBinInfo(out _, out var remainingItems) && remainingItems == 0; } // SHEmptyRecycleBin returns E_UNEXPECTED (0x8000FFFF) when the Recycle Bin is diff --git a/QuickLook/Helpers/ShellItemHelper.cs b/QuickLook/Helpers/ShellItemHelper.cs index 0c7a45604..1becf7acb 100644 --- a/QuickLook/Helpers/ShellItemHelper.cs +++ b/QuickLook/Helpers/ShellItemHelper.cs @@ -96,30 +96,73 @@ public static bool IsShellOnly(string path) /// public static string TryMaterialize(string path) { - if (!IsShellOnly(path)) - return null; + return TryMaterialize(path, CancellationToken.None, null); + } - // Recycle Bin / This PC / ... are only addressed by an exact "::{GUID}" and are handled by CLSIDViewer. - if (IsExactClsidPath(path)) - return null; + /// + /// Cancellable, progress-aware version of . + /// + /// Shell parsing name of the item. + /// Aborts the wait for the copy and discards the temporary file. + /// + /// Receives the number of copied bytes and the expected size (0 when unknown) while a shell copy + /// is running. Called from the worker thread. + /// + /// + /// The copy itself is performed synchronously on the calling thread, therefore callers must not + /// call this method from the UI thread. + /// + public static string TryMaterialize(string path, CancellationToken cancellationToken, + Action progress) + { + try + { + if (!IsShellOnly(path) || IsExactClsidPath(path)) + return null; - // 1) Preferred: ask the shell item directly to copy itself (works for items inside zip folders and - // for shell namespaces that SHCreateItemFromParsingName can resolve). - var materialized = TryMaterializeViaShellItem(path); + // Policy checks before choosing a copy strategy. Returning null here means "do not copy this + // item at all", so the caller must not try another strategy: a folder or an oversized item is + // a deliberate rejection, not a resolution failure. + var hasMetadata = TryGetShellItemMetadata(path, + out _, out var isFolder, out var knownSize, out _, out _); - if (!string.IsNullOrEmpty(materialized)) - return materialized; + if (hasMetadata && isFolder) + return null; + + if (knownSize > MaxMaterializedSize) + return null; + + if (cancellationToken.IsCancellationRequested) + return null; + + // 1) Preferred: ask the shell item directly to copy itself (works for items inside zip folders and + // for shell namespaces that SHCreateItemFromParsingName can resolve). + var materialized = TryMaterializeViaShellItem(path, cancellationToken, progress); + + if (!string.IsNullOrEmpty(materialized)) + return materialized; + + if (cancellationToken.IsCancellationRequested) + return null; - // 2) Portable devices (MTP phones) cannot be resolved from their parsing name on every Windows - // build, but the shell namespace below "This PC" knows them. Walk the namespace along the - // path segments, then copy the matching item. This is the main path for phone files. - materialized = TryMaterializeViaShellNamespace(path); + // 2) Portable devices (MTP phones) cannot be resolved from their parsing name on every Windows + // build, but the shell namespace below "This PC" knows them. Walk the namespace along the + // path segments, then copy the matching item. This is the main path for phone files. + materialized = TryMaterializeViaShellNamespace(path, knownSize, cancellationToken, progress); - if (!string.IsNullOrEmpty(materialized)) - return materialized; + if (!string.IsNullOrEmpty(materialized)) + return materialized; - // 3) Last fallback: the item is the current Explorer selection; ask the shell to copy it. - return TryMaterializeViaExplorerSelection(path); + if (cancellationToken.IsCancellationRequested) + return null; + + // 3) Last fallback: the item is the current Explorer selection; ask the shell to copy it. + return TryMaterializeViaExplorerSelection(path, knownSize, cancellationToken, progress); + } + catch (Exception) + { + return null; + } } /// @@ -127,7 +170,8 @@ public static string TryMaterialize(string path) /// located by walking the "This PC" shell namespace along the segments of its absolute parsing name, /// then copied to a temporary file. Returns null when the walk or the copy fails. /// - private static string TryMaterializeViaShellNamespace(string path) + private static string TryMaterializeViaShellNamespace(string path, long expectedSize, + CancellationToken cancellationToken, Action progress) { var tempDirectory = null as string; @@ -146,6 +190,17 @@ private static string TryMaterializeViaShellNamespace(string path) if ((bool)targetItem.IsFolder) return null; + // Enforce the size limit inside every copy strategy, including items whose metadata was + // not available before this namespace walk. + if (expectedSize <= 0) + expectedSize = ParseHumanSize((string)GetDetailsString(parentFolder, targetItem, 2)); + + if (expectedSize > MaxMaterializedSize) + return null; + + if (cancellationToken.IsCancellationRequested) + return null; + var safeName = MakeSafeFileName((string)targetItem.Name); if (string.IsNullOrEmpty(safeName)) @@ -175,9 +230,12 @@ private static string TryMaterializeViaShellNamespace(string path) string destinationFile; - if (!WaitForCopyToFinish(tempDirectory, out destinationFile)) + if (!WaitForCopyToFinish(tempDirectory, expectedSize, cancellationToken, progress, + out destinationFile)) { - TryDeleteDirectory(tempDirectory); + // The shell copy may still be writing this folder: delete it once the transfer stops + // instead of blocking the worker until the device is idle. + ScheduleTempDirectoryCleanup(tempDirectory); return null; } @@ -195,7 +253,7 @@ private static string TryMaterializeViaShellNamespace(string path) catch (Exception) { if (!string.IsNullOrEmpty(tempDirectory)) - TryDeleteDirectory(tempDirectory); + ScheduleTempDirectoryCleanup(tempDirectory); return null; } @@ -401,8 +459,14 @@ private static bool CountShellFolder(dynamic folder, Func shouldCancel, Ac dynamic subFolder = child.GetFolder; - if (subFolder != null) - CountShellFolder(subFolder, shouldCancel, progress, depth + 1, ref totalSize, ref fileCount, ref folderCount); + if (subFolder != null + && !CountShellFolder(subFolder, shouldCancel, progress, depth + 1, + ref totalSize, ref fileCount, ref folderCount)) + { + // The child enumeration failed or was cancelled: propagate an incomplete + // result so callers do not present the partial total as a final size. + return false; + } } else { @@ -477,14 +541,11 @@ private static long ParseHumanSize(string text) if (!match.Success) return -1; - var numberText = match.Groups[1].Value.Trim().Replace(",", string.Empty); + var numberText = match.Groups[1].Value.Trim(); var unit = match.Groups[2].Value.ToUpperInvariant(); - if (!double.TryParse(numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) - && !double.TryParse(numberText, NumberStyles.Float, CultureInfo.CurrentCulture, out value)) - { + if (!TryParseLocalizedNumber(numberText, out var value)) return -1; - } var exponent = unit.StartsWith("K") ? 1 : unit.StartsWith("M") ? 2 @@ -505,6 +566,41 @@ private static long ParseHumanSize(string text) } } + /// + /// Parses a number rendered by an Explorer column while preserving the decimal separator of the + /// current culture ("4,09 KB" must stay 4.09 and never become 409). Falls back to the other + /// common separators only when the culture-based parse fails. + /// + private static bool TryParseLocalizedNumber(string text, out double value) + { + value = 0; + + if (string.IsNullOrWhiteSpace(text)) + return false; + + const NumberStyles styles = NumberStyles.Float | NumberStyles.AllowThousands; + + if (double.TryParse(text, styles, CultureInfo.CurrentCulture, out value)) + return true; + + var numberFormat = CultureInfo.CurrentCulture.NumberFormat; + var groupsRemoved = text.Replace(numberFormat.NumberGroupSeparator, string.Empty); + + if (double.TryParse(groupsRemoved, styles, CultureInfo.CurrentCulture, out value)) + return true; + + // The column may use a decimal separator that differs from the current culture. Try both + // interpretations, but only when they are unambiguous. + var dotAsDecimal = groupsRemoved.Replace(",", string.Empty); + + if (double.TryParse(dotAsDecimal, NumberStyles.Float, CultureInfo.InvariantCulture, out value)) + return true; + + var commaAsDecimal = groupsRemoved.Replace(".", string.Empty).Replace(',', '.'); + + return double.TryParse(commaAsDecimal, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + private static dynamic FindPrefixItem(dynamic folder, string path) { try @@ -589,18 +685,25 @@ private static dynamic FindExactItem(dynamic folder, string wantedPath) return null; } - private static string TryMaterializeViaShellItem(string path) + private static string TryMaterializeViaShellItem(string path, CancellationToken cancellationToken, + Action progress) { var tempDirectory = null as string; try { - if (ShellItemInfo.TryGetIsFolder(path, out var isFolder) && isFolder) + // Only copy items that the shell positively classifies as a file/stream. When the + // classification fails the item must not reach IFileOperation: it may be a folder, and copying + // a folder would transfer its whole content. + if (!ShellItemInfo.TryGetIsFolder(path, out var isFolder) || isFolder) return null; if (ShellItemInfo.TryGetSize(path, out var size) && size > MaxMaterializedSize) return null; + if (cancellationToken.IsCancellationRequested) + return null; + CleanupStaleTempFilesOnce(); var safeName = MakeSafeFileName(ShellItemInfo.GetDisplayNameOrNull(path)); @@ -626,12 +729,20 @@ private static string TryMaterializeViaShellItem(string path) var destinationFile = Path.Combine(tempDirectory, safeName); + // IFileOperation reports completion synchronously, but it cannot be aborted: check the + // cancellation before and after the copy and discard the temporary file when superseded. if (!CopyShellItem(path, tempDirectory, safeName)) { TryDeleteDirectory(tempDirectory); return null; } + if (cancellationToken.IsCancellationRequested) + { + ScheduleTempDirectoryCleanup(tempDirectory); + return null; + } + if (!File.Exists(destinationFile)) { // The shell may have changed the destination name; accept whatever single file landed in the folder. @@ -654,25 +765,28 @@ private static string TryMaterializeViaShellItem(string path) catch (Exception) { if (!string.IsNullOrEmpty(tempDirectory)) - TryDeleteDirectory(tempDirectory); + ScheduleTempDirectoryCleanup(tempDirectory); return null; } } - /// /// Fallback for shell items whose parsing name cannot be resolved by SHCreateItemFromParsingName /// (typically items of an MTP/portable device, e.g. a photo on a phone). When the item is the /// current Explorer selection - which is exactly the QuickLook preview flow - Shell.Application /// exposes the selected FolderItem and the shell can copy it into a temporary file. /// - private static string TryMaterializeViaExplorerSelection(string path) + private static string TryMaterializeViaExplorerSelection(string path, long expectedSize, + CancellationToken cancellationToken, Action progress) { var tempDirectory = null as string; try { + if (cancellationToken.IsCancellationRequested) + return null; + var shellType = Type.GetTypeFromProgID("Shell.Application"); if (shellType == null) @@ -681,6 +795,7 @@ private static string TryMaterializeViaExplorerSelection(string path) dynamic shell = Activator.CreateInstance(shellType); dynamic windows = shell.Windows(); dynamic sourceItem = null; + string sourceSizeText = null; var windowCount = (int)windows.Count; @@ -705,6 +820,16 @@ private static string TryMaterializeViaExplorerSelection(string path) if (string.Equals(candidatePath, path, StringComparison.OrdinalIgnoreCase)) { sourceItem = candidate; + + try + { + sourceSizeText = (string)document.Folder.GetDetailsOf(candidate, 2); + } + catch (Exception) + { + sourceSizeText = null; + } + break; } } @@ -726,6 +851,15 @@ private static string TryMaterializeViaExplorerSelection(string path) if ((bool)sourceItem.IsFolder) return null; + if (expectedSize <= 0) + expectedSize = ParseHumanSize(sourceSizeText); + + if (expectedSize > MaxMaterializedSize) + return null; + + if (cancellationToken.IsCancellationRequested) + return null; + string safeName = MakeSafeFileName((string)sourceItem.Name); if (string.IsNullOrEmpty(safeName)) @@ -749,9 +883,10 @@ private static string TryMaterializeViaExplorerSelection(string path) var destinationFile = Path.Combine(tempDirectory, safeName); - if (!WaitForCopyToFinish(tempDirectory, out destinationFile)) + if (!WaitForCopyToFinish(tempDirectory, expectedSize, cancellationToken, progress, + out destinationFile)) { - TryDeleteDirectory(tempDirectory); + ScheduleTempDirectoryCleanup(tempDirectory); return null; } @@ -763,7 +898,7 @@ private static string TryMaterializeViaExplorerSelection(string path) catch (Exception) { if (!string.IsNullOrEmpty(tempDirectory)) - TryDeleteDirectory(tempDirectory); + ScheduleTempDirectoryCleanup(tempDirectory); return null; } @@ -773,17 +908,24 @@ private static string TryMaterializeViaExplorerSelection(string path) /// Shell copy operations (Folder.CopyHere) are asynchronous; wait until the copied file exists and /// its size stops growing. Bounded so a stuck device copy can never hang the viewer forever. /// - private static bool WaitForCopyToFinish(string directory, out string resultFile) + private static bool WaitForCopyToFinish(string directory, long expectedSize, + CancellationToken cancellationToken, Action progress, out string resultFile) { resultFile = null; - var timeout = TimeSpan.FromSeconds(30); + // Folder.CopyHere does not report completion. When the shell knows the item size, wait until the + // destination reaches that size; otherwise fall back to a stable-size heuristic. In both cases a + // timeout is a failure: a partial file must never be handed to a preview plugin. + var timeout = expectedSize > 0 ? TimeSpan.FromMinutes(15) : TimeSpan.FromSeconds(30); var stopwatch = Stopwatch.StartNew(); long lastSize = -1; var stableSamples = 0; while (stopwatch.Elapsed < timeout) { + if (cancellationToken.IsCancellationRequested) + return false; + string[] files; try @@ -809,15 +951,40 @@ private static bool WaitForCopyToFinish(string directory, out string resultFile) size = 0; } - if (size > 0 && size == lastSize) + if (progress != null) { - stableSamples++; + try + { + progress(size, expectedSize); + } + catch (Exception) + { + // Progress reporting must never break the copy. + } + } - if (stableSamples >= 3) // ~750 ms of a stable size + resultFile = candidate; + + if (expectedSize > 0) + { + if (size >= expectedSize) { - resultFile = candidate; - return true; + stableSamples++; + + if (stableSamples >= 2) // ~500 ms: let the shell flush the last block + return true; } + else + { + stableSamples = 0; + } + } + else if (size > 0 && size == lastSize) + { + stableSamples++; + + if (stableSamples >= 8) // ~2 s without any progress + return true; } else { @@ -825,13 +992,99 @@ private static bool WaitForCopyToFinish(string directory, out string resultFile) } lastSize = size; - resultFile = candidate; } Thread.Sleep(250); } - return !string.IsNullOrEmpty(resultFile) && File.Exists(resultFile); + // The copy did not finish in time; never report the (possibly partial) destination as success. + resultFile = null; + return false; + } + + /// + /// Deletes a temporary directory after a shell copy into it has stopped writing. Folder.CopyHere + /// runs asynchronously, so a cancelled or failed copy may still be using the directory; deleting it + /// immediately would either fail or make the shell recreate files. Runs on a background thread. + /// + private static void ScheduleTempDirectoryCleanup(string tempDirectory) + { + if (string.IsNullOrEmpty(tempDirectory) || !IsUnderTempRoot(tempDirectory)) + return; + + try + { + var thread = new Thread(() => + { + var deadline = DateTime.UtcNow.AddMinutes(10); + long lastSize = -1; + var stableSamples = 0; + var emptySamples = 0; + + while (DateTime.UtcNow < deadline) + { + try + { + var files = Directory.GetFiles(tempDirectory); + long size = 0; + + if (files.Length > 0) + { + try + { + size = new FileInfo(files[0]).Length; + } + catch (Exception) + { + size = 0; + } + } + + if (files.Length == 0) + { + emptySamples++; + + if (emptySamples >= 8) // no copy ever started; remove the empty directory + { + TryDeleteDirectory(tempDirectory); + return; + } + } + else if (size > 0 && size == lastSize) + { + stableSamples++; + + if (stableSamples >= 8) // ~2 s without progress: the transfer is over + { + TryDeleteDirectory(tempDirectory); + return; + } + } + else + { + stableSamples = 0; + } + + lastSize = size; + } + catch (Exception) + { + return; + } + + Thread.Sleep(250); + } + + TryDeleteDirectory(tempDirectory); + }) + { IsBackground = true, Name = "QuickLook shell item cleanup" }; + + thread.Start(); + } + catch (Exception) + { + // Best effort only. + } } /// diff --git a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs index 26461b7f7..91b23d179 100644 --- a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs +++ b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs @@ -31,6 +31,7 @@ public partial class InfoPanel : UserControl private const string UnknownValue = "—"; private bool _stop; + private bool _statusOverride; public InfoPanel() { @@ -43,6 +44,17 @@ public bool Stop get => _stop; } + /// + /// Shows a custom status line instead of the size. Used while a shell item is copied to a + /// temporary file; the normal metadata updates are suppressed until this panel is replaced. + /// + public void SetStatusText(string text) + { + _statusOverride = true; + + RunOnUiThread(() => { totalSize.Text = text; }); + } + public void DisplayInfo(string path) { // DisplayInfo is called for *any* path Explorer hands over, including shell parsing names of @@ -106,7 +118,7 @@ private void DisplayThumbnail(string path) private void UpdateSizeText(string path) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrEmpty(path) || _statusOverride) return; long? fileSize = null; @@ -223,6 +235,16 @@ private void UpdateSizeText(string path) if (!Stop) RunOnUiThread(() => { totalSize.Text = FormatFolderSizeText(folderSize, folderDirs, folderFiles); }); } + else if (!Stop) + { + // The enumeration failed (device unplugged, access denied, safety limit, ...): do not + // leave "Calculating size…" behind and do not present a partial total as final size. + var unavailable = string.IsNullOrEmpty(shellOnlyType) + ? TranslationHelper.Get("InfoPanel_SizeUnavailable", failsafe: "Size unavailable") + : shellOnlyType; + + RunOnUiThread(() => { totalSize.Text = unavailable; }); + } return; } diff --git a/QuickLook/Plugin/InfoPanel/ShellItemInfo.cs b/QuickLook/Plugin/InfoPanel/ShellItemInfo.cs index e572a1fe3..bd811b1de 100644 --- a/QuickLook/Plugin/InfoPanel/ShellItemInfo.cs +++ b/QuickLook/Plugin/InfoPanel/ShellItemInfo.cs @@ -244,7 +244,12 @@ public static bool TryGetIsFolder(string path, out bool isFolder) try { - if (item.GetAttributes(SFGAO_FOLDER | SFGAO_STREAM, out var attributes) != 0) + // GetAttributes returns S_FALSE (1) when the returned attributes do not match the requested + // mask exactly; only a negative HRESULT is a real failure. Rejecting S_FALSE would report + // every folder as "not a folder". + var hr = item.GetAttributes(SFGAO_FOLDER | SFGAO_STREAM, out var attributes); + + if (hr < 0) return false; isFolder = (attributes & SFGAO_FOLDER) != 0 && (attributes & SFGAO_STREAM) == 0; @@ -354,6 +359,9 @@ private interface IShellItem2 [PreserveSig] int GetPropertyStore(uint flags, ref Guid riid, out IntPtr ppv); + [PreserveSig] + int GetPropertyStoreWithCreateObject(uint flags, IntPtr punkCreateObject, ref Guid riid, out IntPtr ppv); + [PreserveSig] int GetPropertyStoreForKeys(IntPtr rgKeys, uint cKeys, uint flags, ref Guid riid, out IntPtr ppv); @@ -386,8 +394,5 @@ private interface IShellItem2 [PreserveSig] int GetBool(ref PROPERTYKEY key, out int pf); - - [PreserveSig] - int GetIcon(ref PROPERTYKEY key, uint flags, out IntPtr phicon); } } diff --git a/QuickLook/PreparingPreview.cs b/QuickLook/PreparingPreview.cs new file mode 100644 index 000000000..9e615b320 --- /dev/null +++ b/QuickLook/PreparingPreview.cs @@ -0,0 +1,77 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using QuickLook.Common.Plugin; +using QuickLook.Plugin.InfoPanel; +using System.Windows; + +namespace QuickLook; + +/// +/// Placeholder preview shown immediately while a shell-only item (for example a large file on an +/// MTP phone) is copied to a temporary file. The copy runs on a background worker, so the UI stays +/// responsive: pressing Space on another file or closing the window cancels the pending copy. +/// +internal sealed class PreparingPreview : IViewer +{ + private InfoPanel _panel; + + public int Priority => int.MinValue; + + public void Init() + { + } + + public bool CanHandle(string sample) + { + return false; + } + + public void Prepare(string path, ContextObject context) + { + context.PreferredSize = new Size { Width = 453, Height = 172 }; + context.Title = InfoPanel.GetSafeDisplayName(path); + context.TitlebarOverlap = false; + context.TitlebarBlurVisibility = false; + context.TitlebarColourVisibility = false; + context.CanResize = false; + context.FullWindowDragging = true; + } + + public void View(string path, ContextObject context) + { + _panel = new InfoPanel(); + context.ViewerContent = _panel; + _panel.DisplayInfo(path); + _panel.SetStatusText("Preparing preview…"); + context.IsBusy = true; + } + + public void SetStatus(string text) + { + _panel?.SetStatusText(text); + } + + public void Cleanup() + { + if (_panel == null) + return; + + _panel.Stop = true; + _panel = null; + } +} diff --git a/QuickLook/Translations.config b/QuickLook/Translations.config index 714740627..d4324aa42 100644 --- a/QuickLook/Translations.config +++ b/QuickLook/Translations.config @@ -266,6 +266,7 @@ {0} files ({0} and {1}) Calculating size… + Size unavailable Cancellation of "Prevent Closing" is not supported Copy as path Copy successfully diff --git a/QuickLook/ViewWindowManager.cs b/QuickLook/ViewWindowManager.cs index bb6c94eb8..5ed735f56 100644 --- a/QuickLook/ViewWindowManager.cs +++ b/QuickLook/ViewWindowManager.cs @@ -15,6 +15,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using QuickLook.Common.ExtensionMethods; using QuickLook.Common.Helpers; using QuickLook.Common.Plugin; using QuickLook.Helpers; @@ -22,7 +23,9 @@ using System.Diagnostics; using System.IO; using System.Runtime.ExceptionServices; +using System.Threading; using System.Threading.Tasks; +using System.Windows; namespace QuickLook; @@ -33,6 +36,9 @@ public class ViewWindowManager : IDisposable private string _invokedPath = string.Empty; private string _previewPath = string.Empty; private string _materializedTempFile; + private PreparingPreview _preparingPreview; + private CancellationTokenSource _materializeCts; + private long _previewRequestId; private ViewerWindow _viewerWindow; internal ViewWindowManager() @@ -42,6 +48,7 @@ internal ViewWindowManager() public void Dispose() { + CancelPendingMaterialization(); StopFocusMonitor(); } @@ -154,113 +161,282 @@ public void InvokePreview(string path = null) if (_viewerWindow.IsVisible && path == _invokedPath) return; - var previewPath = path; - var useDefaultPlugin = false; - IViewer matchedPlugin = null; - var isDirectory = Directory.Exists(path); if (!isDirectory && !File.Exists(path)) { - if (!path.StartsWith("::", StringComparison.Ordinal)) // CLSID + // Shell-only item (file on an MTP device, item inside a zip folder, ...). Shell parsing names + // are not file system paths, so metadata is resolved first: folders and oversized items are + // never copied, files are prepared on a background worker. + var hasMetadata = ShellItemHelper.TryGetShellItemMetadata(path, + out _, out var shellItemIsFolder, out var shellItemSize, out _, out _); + + if (!hasMetadata && !path.StartsWith("::", StringComparison.Ordinal)) + { + // An unresolvable plain path (neither a file, a folder nor a shell namespace name): ignore + // it instead of opening the info panel for something that does not exist. return; + } - // Shell parsing names of MTP items do not contain the file name (the last segment is an item - // id). A folder is shown by the info panel: its size is computed from shell metadata and - // nothing is copied. Files are copied to a temporary file so that the regular plugins (image, - // video, archive, text, ...) can preview them exactly like before. - var hasShellMetadata = ShellItemHelper.TryGetShellItemMetadata(path, - out _, out var shellItemIsFolder, out _, out _, out _); + if (hasMetadata && shellItemIsFolder) + { + // Folders are never copied: the info panel shows the real name/date and computes the size + // from shell metadata. + ShowPreview(path, path, useDefaultPlugin: true); + return; + } - if (hasShellMetadata && shellItemIsFolder) + if (shellItemSize > ShellItemHelper.MaxMaterializedSize) { - previewPath = path; - useDefaultPlugin = true; + // Too large to copy to a temporary file: show the shell metadata only. + ShowPreview(path, path, useDefaultPlugin: true); + return; } - else + + // Show the info panel immediately and copy the file on a cancellable background worker. This + // keeps the UI responsive for large files and lets a newer preview supersede this one. + ShowPreparingPreview(path); + return; + } + + if (!isDirectory && !ExtensionFilterHelper.IsExtensionAllowed(path)) + return; + + ShowPreview(path, path, useDefaultPlugin: false); + } + + /// + /// Shows a preview that needs no shell copy (a local file/folder, an info panel item, ...). + /// + private void ShowPreview(string invokedPath, string previewPath, bool useDefaultPlugin) + { + CancelPendingMaterialization(); + ReleasePreviousMaterializedFile(); + + _invokedPath = invokedPath; + _previewPath = previewPath; + _preparingPreview = null; + + RunFocusMonitor(); + + var matchedPlugin = useDefaultPlugin + ? CreateDefaultPluginInstance() + : PluginManager.GetInstance().FindMatch(previewPath); + + BeginShowNewWindow(previewPath, matchedPlugin); + } + + /// + /// Shows the info panel immediately and copies the shell item on a background STA worker. The + /// copy result replaces the placeholder only when this request is still the latest one. + /// + private void ShowPreparingPreview(string shellPath) + { + CancelPendingMaterialization(); + ReleasePreviousMaterializedFile(); + + var preparing = new PreparingPreview(); + + _preparingPreview = preparing; + _invokedPath = shellPath; + _previewPath = shellPath; + + RunFocusMonitor(); + BeginShowNewWindow(shellPath, preparing); + + var requestId = Interlocked.Increment(ref _previewRequestId); + StartMaterialization(shellPath, requestId); + } + + private void StartMaterialization(string shellPath, long requestId) + { + var cancellation = new CancellationTokenSource(); + _materializeCts = cancellation; + + var token = cancellation.Token; + + var worker = new Thread(() => + { + string tempFile = null; + + try { - var materializedPath = ShellItemHelper.TryMaterialize(path); + tempFile = ShellItemHelper.TryMaterialize(shellPath, token, + (copied, expected) => ReportCopyProgress(requestId, copied, expected)); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } - if (!string.IsNullOrEmpty(materializedPath)) - { - // Extension filter settings must also protect previews coming from a device. - if (!ExtensionFilterHelper.IsExtensionAllowed(materializedPath)) - { - ShellItemHelper.TryDeleteTempFile(materializedPath); - return; - } - - // Some plugins (video, media info, ...) inspect the content instead of the extension, - // so the plugin match can only be decided on a real local file. When no plugin can - // preview it, the info panel shows the original shell item (real name/date/size); - // the temporary copy is then dropped so nothing unnecessary is kept on disk. - var matchedForCopy = PluginManager.GetInstance().FindMatch(materializedPath); - - if (matchedForCopy is Plugin.InfoPanel.Plugin) - { - ShellItemHelper.TryDeleteTempFile(materializedPath); - previewPath = path; - useDefaultPlugin = true; - } - else - { - matchedPlugin = matchedForCopy; - previewPath = materializedPath; - - // Delete the temporary copy when the preview window closes (or after a grace - // period when another preview replaces this one, see below). - _materializedTempFile = previewPath; - _viewerWindow.Closed += (sender, e) => - { - var tempFileToDelete = _materializedTempFile; - _materializedTempFile = null; - - if (!string.IsNullOrEmpty(tempFileToDelete)) - { - _ = Task.Delay(TimeSpan.FromSeconds(5)) - .ContinueWith(_ => ShellItemHelper.TryDeleteTempFile(tempFileToDelete), TaskScheduler.Default); - } - }; - } - } - else + if (_materializeCts == cancellation) + _materializeCts = null; + + if (token.IsCancellationRequested) + { + // Superseded or cancelled: the partial copy is discarded. + if (!string.IsNullOrEmpty(tempFile)) + ScheduleTempFileCleanup(tempFile); + + return; + } + + var dispatcher = Application.Current?.Dispatcher; + + if (dispatcher == null) + return; + + dispatcher.BeginInvoke(new Action(() => + { + if (requestId != Interlocked.Read(ref _previewRequestId) || !_viewerWindow.IsVisible) { - // Exact "::{GUID}" namespace roots (Recycle Bin, This PC, ...) are handled by CLSIDViewer. - // Any other shell-only item that could not be copied (too large, access denied, ...) must - // never be handed to a file based plugin: it cannot open such a path. - previewPath = path; - useDefaultPlugin = !ShellItemHelper.IsExactClsidPath(path); + // A newer preview was requested (or the window was closed) while this copy was running. + if (!string.IsNullOrEmpty(tempFile)) + ScheduleTempFileCleanup(tempFile); + + return; } - } + + CompleteMaterialization(shellPath, tempFile); + })); + }) + { + IsBackground = true, + Name = "QuickLook shell item copy" + }; + + // Shell.Application and IFileOperation expect an STA thread. + worker.SetApartmentState(ApartmentState.STA); + worker.Start(); + } + + private void CompleteMaterialization(string invokedPath, string tempFile) + { + if (string.IsNullOrEmpty(tempFile) || !File.Exists(tempFile)) + { + // The copy failed (timeout, device disconnected, ...). Never hand a partial file to a plugin: + // show the shell metadata instead. + ShowPreview(invokedPath, invokedPath, useDefaultPlugin: true); + return; } - else if (!isDirectory && !ExtensionFilterHelper.IsExtensionAllowed(path)) + + if (!ExtensionFilterHelper.IsExtensionAllowed(tempFile)) { - // Check extension filtering before proceeding (skip for directories) + ShellItemHelper.TryDeleteTempFile(tempFile); + ShowPreview(invokedPath, invokedPath, useDefaultPlugin: true); return; } - // A new preview replaces the previous one: drop the temporary copy of the replaced shell item. - // The previous plugin may still be finishing an asynchronous load of that file (image decoding, - // archive listing, ...), therefore remove it after a grace period instead of immediately. - var previousTempFile = _materializedTempFile; + var matchedPlugin = PluginManager.GetInstance().FindMatch(tempFile); - if (!string.IsNullOrEmpty(previousTempFile)) + if (matchedPlugin is Plugin.InfoPanel.Plugin) { - _materializedTempFile = null; - _ = Task.Delay(TimeSpan.FromSeconds(20)) - .ContinueWith(_ => ShellItemHelper.TryDeleteTempFile(previousTempFile), TaskScheduler.Default); + // No plugin can preview this file; the info panel shows the real shell metadata, so the + // temporary copy is not needed at all. + ShellItemHelper.TryDeleteTempFile(tempFile); + ShowPreview(invokedPath, invokedPath, useDefaultPlugin: true); + return; } - _invokedPath = path; - _previewPath = previewPath; + ReleasePreviousMaterializedFile(); + + _invokedPath = invokedPath; + _previewPath = tempFile; + _materializedTempFile = tempFile; + _preparingPreview = null; RunFocusMonitor(); - matchedPlugin ??= useDefaultPlugin - ? PluginManager.GetInstance().DefaultPlugin - : PluginManager.GetInstance().FindMatch(previewPath); + BeginShowNewWindow(tempFile, matchedPlugin); + } - BeginShowNewWindow(previewPath, matchedPlugin); + private void ReportCopyProgress(long requestId, long copiedBytes, long expectedBytes) + { + if (requestId != Interlocked.Read(ref _previewRequestId)) + return; + + var preparing = _preparingPreview; + + if (preparing == null) + return; + + var status = expectedBytes > 0 + ? $"Preparing preview… {copiedBytes.ToPrettySize(2)} of {expectedBytes.ToPrettySize(2)}" + : $"Preparing preview… {copiedBytes.ToPrettySize(2)}"; + + var dispatcher = Application.Current?.Dispatcher; + + dispatcher?.BeginInvoke(new Action(() => + { + if (requestId == Interlocked.Read(ref _previewRequestId) + && _preparingPreview == preparing + && _viewerWindow.IsVisible) + { + preparing.SetStatus(status); + } + })); + } + + /// + /// Cancels the shell item copy that is currently running, if any. The copy itself cannot always be + /// aborted inside the shell, but its result is discarded and the temporary file is cleaned up. + /// + private void CancelPendingMaterialization() + { + // Invalidate any completion that is already queued on the dispatcher. + Interlocked.Increment(ref _previewRequestId); + + var cancellation = _materializeCts; + + if (cancellation == null) + return; + + _materializeCts = null; + + try + { + cancellation.Cancel(); + } + catch (Exception) + { + // ignored + } + + cancellation.Dispose(); + } + + /// + /// Drops the temporary copy owned by the preview that is being replaced. The previous plugin may + /// still be reading that file asynchronously, so removal is deferred. + /// + private void ReleasePreviousMaterializedFile() + { + var previousTempFile = _materializedTempFile; + _materializedTempFile = null; + + if (!string.IsNullOrEmpty(previousTempFile)) + ScheduleTempFileCleanup(previousTempFile); + } + + private static void ScheduleTempFileCleanup(string tempFile) + { + if (string.IsNullOrEmpty(tempFile)) + return; + + _ = Task.Delay(TimeSpan.FromSeconds(20)) + .ContinueWith(_ => ShellItemHelper.TryDeleteTempFile(tempFile), TaskScheduler.Default); + } + + /// + /// Creates a fresh instance of the default plugin for every preview. Reusing the single instance + /// stored in would let a closing pinned window stop the + /// info panel of a newer preview. + /// + private static IViewer CreateDefaultPluginInstance() + { + var defaultPlugin = PluginManager.GetInstance().DefaultPlugin; + + return defaultPlugin.GetType().CreateInstance(); } public void InvokePluginPreview(string plugin, string path = null) @@ -271,6 +447,8 @@ public void InvokePluginPreview(string plugin, string path = null) if (string.IsNullOrEmpty(path)) return; + CancelPendingMaterialization(); + var isDirectory = Directory.Exists(path); if (!isDirectory && !File.Exists(path)) return; @@ -297,6 +475,8 @@ public void ReloadPreview() if (!_viewerWindow.IsVisible || string.IsNullOrEmpty(_invokedPath)) return; + CancelPendingMaterialization(); + // For shell-only items the preview is shown from a materialized temporary file; prefer it while // it still exists and fall back to the original path otherwise. var previewPath = _previewPath; @@ -338,7 +518,7 @@ private void CurrentPluginFailed(string path, ExceptionDispatchInfo e) ProcessHelper.WriteLog(e.SourceException.ToString()); if (plugin != PluginManager.GetInstance().DefaultPlugin.GetType()) - BeginShowNewWindow(path, PluginManager.GetInstance().DefaultPlugin); + BeginShowNewWindow(path, CreateDefaultPluginInstance()); else e.Throw(); } @@ -356,6 +536,8 @@ private void InitNewViewerWindow() // which sets Pinned=true AND replaces _viewerWindow with a new instance. if (w.Pinned && _viewerWindow != w) return; + CancelPendingMaterialization(); + _preparingPreview = null; StopFocusMonitor(); InitNewViewerWindow(); }; From 6fa4815a4d7fb38c938d0d66cb46fb12cb7144cc Mon Sep 17 00:00:00 2001 From: mohamadsm313m-prog Date: Fri, 13 Nov 2026 18:31:06 +0330 Subject: [PATCH 4/6] Show shell-only previews instantly and resolve metadata off the UI thread The placeholder window is now created before any shell call, so an MTP item appears in well under a second. The background worker resolves the display name/metadata (short lived metadata cache keeps the repeated lookups cheap), updates the placeholder title/name, and then copies the item or falls back to the info panel for folders and oversized items. Verified with a 182 MB file on a phone: placeholder in ~0.4 s, another preview opened in ~0.3 s while the copy was still running, and the abandoned partial copy was cleaned up. --- QuickLook/Helpers/ShellItemHelper.cs | 89 ++++++++++++++ QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs | 11 ++ QuickLook/PreparingPreview.cs | 20 +++- QuickLook/ViewWindowManager.cs | 115 ++++++++++++------- 4 files changed, 193 insertions(+), 42 deletions(-) diff --git a/QuickLook/Helpers/ShellItemHelper.cs b/QuickLook/Helpers/ShellItemHelper.cs index 1becf7acb..d8374d85f 100644 --- a/QuickLook/Helpers/ShellItemHelper.cs +++ b/QuickLook/Helpers/ShellItemHelper.cs @@ -21,6 +21,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; @@ -52,6 +53,15 @@ public static class ShellItemHelper private static readonly HashSet ActiveTempFiles = new HashSet(StringComparer.OrdinalIgnoreCase); private static bool _staleCleanupDone; + // Metadata of a shell item is requested several times per preview (routing, placeholder title, + // info panel); walking an MTP namespace for every request is slow. This short lived cache keeps the + // UI thread responsive by reusing the result of the background resolution. + private static readonly Dictionary MetadataCache = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static readonly object MetadataCacheLock = new object(); + private static readonly TimeSpan MetadataCacheLifetime = TimeSpan.FromSeconds(5); + private static readonly HashSet ReservedFileNames = new HashSet( new[] { @@ -344,6 +354,9 @@ public static bool TryGetShellItemMetadata(string path, modified = null; typeText = null; + if (TryGetCachedMetadata(path, out name, out isFolder, out size, out modified, out typeText)) + return true; + dynamic item; dynamic parentFolder; @@ -369,6 +382,7 @@ public static bool TryGetShellItemMetadata(string path, modified = parsed; } + CacheMetadata(path, name, isFolder, size, modified, typeText); return !string.IsNullOrEmpty(name); } finally @@ -383,6 +397,81 @@ public static bool TryGetShellItemMetadata(string path, } } + private sealed class CachedShellMetadata + { + public string Name; + public bool IsFolder; + public long Size; + public DateTime? Modified; + public string TypeText; + public DateTime CreatedUtc; + } + + private static bool TryGetCachedMetadata(string path, out string name, out bool isFolder, + out long size, out DateTime? modified, out string typeText) + { + name = null; + isFolder = false; + size = -1; + modified = null; + typeText = null; + + if (string.IsNullOrEmpty(path)) + return false; + + lock (MetadataCacheLock) + { + if (!MetadataCache.TryGetValue(path, out var cached)) + return false; + + if (DateTime.UtcNow - cached.CreatedUtc > MetadataCacheLifetime) + { + MetadataCache.Remove(path); + return false; + } + + name = cached.Name; + isFolder = cached.IsFolder; + size = cached.Size; + modified = cached.Modified; + typeText = cached.TypeText; + return true; + } + } + + private static void CacheMetadata(string path, string name, bool isFolder, long size, + DateTime? modified, string typeText) + { + if (string.IsNullOrEmpty(path)) + return; + + lock (MetadataCacheLock) + { + if (MetadataCache.Count > 64) + { + var now = DateTime.UtcNow; + + foreach (var key in MetadataCache + .Where(pair => now - pair.Value.CreatedUtc > MetadataCacheLifetime) + .Select(pair => pair.Key) + .ToList()) + { + MetadataCache.Remove(key); + } + } + + MetadataCache[path] = new CachedShellMetadata + { + Name = name, + IsFolder = isFolder, + Size = size, + Modified = modified, + TypeText = typeText, + CreatedUtc = DateTime.UtcNow + }; + } + } + /// /// Computes the total size and content count of a shell-only folder (e.g. a folder on an MTP /// device) by enumerating the shell namespace. Cancellable through diff --git a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs index 91b23d179..e45e784e7 100644 --- a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs +++ b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs @@ -55,6 +55,17 @@ public void SetStatusText(string text) RunOnUiThread(() => { totalSize.Text = text; }); } + /// + /// Shows a display name without running the full metadata resolution. Used by the placeholder + /// preview while a shell item is being copied. + /// + public void SetDisplayName(string name) + { + var text = string.IsNullOrEmpty(name) ? UnknownValue : name; + + RunOnUiThread(() => { filename.Text = text; }); + } + public void DisplayInfo(string path) { // DisplayInfo is called for *any* path Explorer hands over, including shell parsing names of diff --git a/QuickLook/PreparingPreview.cs b/QuickLook/PreparingPreview.cs index 9e615b320..21a62df99 100644 --- a/QuickLook/PreparingPreview.cs +++ b/QuickLook/PreparingPreview.cs @@ -29,6 +29,7 @@ namespace QuickLook; internal sealed class PreparingPreview : IViewer { private InfoPanel _panel; + private ContextObject _context; public int Priority => int.MinValue; @@ -43,8 +44,9 @@ public bool CanHandle(string sample) public void Prepare(string path, ContextObject context) { + _context = context; context.PreferredSize = new Size { Width = 453, Height = 172 }; - context.Title = InfoPanel.GetSafeDisplayName(path); + context.Title = "Preparing preview…"; context.TitlebarOverlap = false; context.TitlebarBlurVisibility = false; context.TitlebarColourVisibility = false; @@ -56,11 +58,23 @@ public void View(string path, ContextObject context) { _panel = new InfoPanel(); context.ViewerContent = _panel; - _panel.DisplayInfo(path); _panel.SetStatusText("Preparing preview…"); context.IsBusy = true; } + /// Updates the display name once the background resolution knows it. + public void SetDisplayName(string name) + { + _panel?.SetDisplayName(name); + } + + /// Updates the window title once the background resolution knows it. + public void SetTitle(string title) + { + if (_context != null) + _context.Title = title; + } + public void SetStatus(string text) { _panel?.SetStatusText(text); @@ -68,6 +82,8 @@ public void SetStatus(string text) public void Cleanup() { + _context = null; + if (_panel == null) return; diff --git a/QuickLook/ViewWindowManager.cs b/QuickLook/ViewWindowManager.cs index 5ed735f56..5c8e0766f 100644 --- a/QuickLook/ViewWindowManager.cs +++ b/QuickLook/ViewWindowManager.cs @@ -165,36 +165,23 @@ public void InvokePreview(string path = null) if (!isDirectory && !File.Exists(path)) { - // Shell-only item (file on an MTP device, item inside a zip folder, ...). Shell parsing names - // are not file system paths, so metadata is resolved first: folders and oversized items are - // never copied, files are prepared on a background worker. - var hasMetadata = ShellItemHelper.TryGetShellItemMetadata(path, - out _, out var shellItemIsFolder, out var shellItemSize, out _, out _); - - if (!hasMetadata && !path.StartsWith("::", StringComparison.Ordinal)) - { - // An unresolvable plain path (neither a file, a folder nor a shell namespace name): ignore - // it instead of opening the info panel for something that does not exist. - return; - } - - if (hasMetadata && shellItemIsFolder) + if (ShellItemHelper.IsExactClsidPath(path)) { - // Folders are never copied: the info panel shows the real name/date and computes the size - // from shell metadata. - ShowPreview(path, path, useDefaultPlugin: true); + // Recycle Bin / This PC / ... are handled by CLSIDViewer. + ShowPreview(path, path, useDefaultPlugin: false); return; } - if (shellItemSize > ShellItemHelper.MaxMaterializedSize) + if (!path.StartsWith("::", StringComparison.Ordinal) + && !ShellItemHelper.TryGetShellItemMetadata(path, out _, out _, out _, out _, out _)) { - // Too large to copy to a temporary file: show the shell metadata only. - ShowPreview(path, path, useDefaultPlugin: true); + // An unresolvable plain path (neither a file, a folder nor a shell namespace name): ignore + // it instead of opening the info panel for something that does not exist. return; } - // Show the info panel immediately and copy the file on a cancellable background worker. This - // keeps the UI responsive for large files and lets a newer preview supersede this one. + // Show the placeholder window immediately. The worker resolves the metadata and copies the + // item in the background, so even a multi-gigabyte device file never blocks the UI thread. ShowPreparingPreview(path); return; } @@ -261,6 +248,20 @@ private void StartMaterialization(string shellPath, long requestId) try { + var hasMetadata = ShellItemHelper.TryGetShellItemMetadata(shellPath, + out var displayName, out var isFolder, out var knownSize, out _, out _); + + if (hasMetadata && !string.IsNullOrEmpty(displayName)) + UpdatePreparingInfo(requestId, displayName); + + if (hasMetadata && (isFolder || knownSize > ShellItemHelper.MaxMaterializedSize)) + { + // Nothing is copied for a folder or an oversized item: the info panel shows the shell + // metadata (name, date, type, computed folder size) instead. + PostCompletion(requestId, shellPath, null); + return; + } + tempFile = ShellItemHelper.TryMaterialize(shellPath, token, (copied, expected) => ReportCopyProgress(requestId, copied, expected)); } @@ -281,24 +282,7 @@ private void StartMaterialization(string shellPath, long requestId) return; } - var dispatcher = Application.Current?.Dispatcher; - - if (dispatcher == null) - return; - - dispatcher.BeginInvoke(new Action(() => - { - if (requestId != Interlocked.Read(ref _previewRequestId) || !_viewerWindow.IsVisible) - { - // A newer preview was requested (or the window was closed) while this copy was running. - if (!string.IsNullOrEmpty(tempFile)) - ScheduleTempFileCleanup(tempFile); - - return; - } - - CompleteMaterialization(shellPath, tempFile); - })); + PostCompletion(requestId, shellPath, tempFile); }) { IsBackground = true, @@ -377,6 +361,57 @@ private void ReportCopyProgress(long requestId, long copiedBytes, long expectedB })); } + /// + /// Applies the result of the background preparation on the UI thread, but only when this request is + /// still the latest one and the window is still open. + /// + private void PostCompletion(long requestId, string shellPath, string tempFile) + { + var dispatcher = Application.Current?.Dispatcher; + + if (dispatcher == null) + return; + + dispatcher.BeginInvoke(new Action(() => + { + if (requestId != Interlocked.Read(ref _previewRequestId) || !_viewerWindow.IsVisible) + { + // A newer preview was requested (or the window was closed) while this copy was running. + if (!string.IsNullOrEmpty(tempFile)) + ScheduleTempFileCleanup(tempFile); + + return; + } + + CompleteMaterialization(shellPath, tempFile); + })); + } + + /// + /// Updates the placeholder title/name once the background resolution knows the shell display name. + /// + private void UpdatePreparingInfo(long requestId, string displayName) + { + if (requestId != Interlocked.Read(ref _previewRequestId)) + return; + + var dispatcher = Application.Current?.Dispatcher; + + dispatcher?.BeginInvoke(new Action(() => + { + if (requestId != Interlocked.Read(ref _previewRequestId)) + return; + + var preparing = _preparingPreview; + + if (preparing == null) + return; + + preparing.SetTitle(displayName); + preparing.SetDisplayName(displayName); + })); + } + /// /// Cancels the shell item copy that is currently running, if any. The copy itself cannot always be /// aborted inside the shell, but its result is discarded and the temporary file is cleaned up. From 486d5b03ae4a7ed19b63f8eb3618dae36c4e72c0 Mon Sep 17 00:00:00 2001 From: mohamadsm313m-prog Date: Fri, 13 Nov 2026 18:52:20 +0330 Subject: [PATCH 5/6] Address review feedback on copy completion and cancellation Use the exact PKEY_Size value (when available) as the expected copy size and wait longer for completion when only the rounded display size is known; retry temporary-directory cleanup until its deadline instead of giving up after one failure; keep the preparing placeholder visible (no busy overlay); clear the worker CTS atomically; only cancel the pending preparation in InvokePluginPreview after a plugin match is ready to be shown. --- QuickLook/Helpers/ShellItemHelper.cs | 57 +++++++++++++++++++++------- QuickLook/PreparingPreview.cs | 5 ++- QuickLook/ViewWindowManager.cs | 8 ++-- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/QuickLook/Helpers/ShellItemHelper.cs b/QuickLook/Helpers/ShellItemHelper.cs index d8374d85f..e0f48c808 100644 --- a/QuickLook/Helpers/ShellItemHelper.cs +++ b/QuickLook/Helpers/ShellItemHelper.cs @@ -136,6 +136,14 @@ public static string TryMaterialize(string path, CancellationToken cancellationT var hasMetadata = TryGetShellItemMetadata(path, out _, out var isFolder, out var knownSize, out _, out _); + // Prefer the exact byte size from the property system (PKEY_Size). The Explorer column used by + // TryGetShellItemMetadata is a rounded, localized display value; only the exact value can be + // used to detect a completed copy. + var exactSizeKnown = ShellItemInfo.TryGetSize(path, out var exactSize) && exactSize > 0; + + if (exactSizeKnown) + knownSize = exactSize; + if (hasMetadata && isFolder) return null; @@ -158,7 +166,7 @@ public static string TryMaterialize(string path, CancellationToken cancellationT // 2) Portable devices (MTP phones) cannot be resolved from their parsing name on every Windows // build, but the shell namespace below "This PC" knows them. Walk the namespace along the // path segments, then copy the matching item. This is the main path for phone files. - materialized = TryMaterializeViaShellNamespace(path, knownSize, cancellationToken, progress); + materialized = TryMaterializeViaShellNamespace(path, knownSize, exactSizeKnown, cancellationToken, progress); if (!string.IsNullOrEmpty(materialized)) return materialized; @@ -167,7 +175,7 @@ public static string TryMaterialize(string path, CancellationToken cancellationT return null; // 3) Last fallback: the item is the current Explorer selection; ask the shell to copy it. - return TryMaterializeViaExplorerSelection(path, knownSize, cancellationToken, progress); + return TryMaterializeViaExplorerSelection(path, knownSize, exactSizeKnown, cancellationToken, progress); } catch (Exception) { @@ -181,7 +189,7 @@ public static string TryMaterialize(string path, CancellationToken cancellationT /// then copied to a temporary file. Returns null when the walk or the copy fails. /// private static string TryMaterializeViaShellNamespace(string path, long expectedSize, - CancellationToken cancellationToken, Action progress) + bool expectedSizeIsExact, CancellationToken cancellationToken, Action progress) { var tempDirectory = null as string; @@ -203,7 +211,10 @@ private static string TryMaterializeViaShellNamespace(string path, long expected // Enforce the size limit inside every copy strategy, including items whose metadata was // not available before this namespace walk. if (expectedSize <= 0) + { expectedSize = ParseHumanSize((string)GetDetailsString(parentFolder, targetItem, 2)); + expectedSizeIsExact = false; + } if (expectedSize > MaxMaterializedSize) return null; @@ -240,8 +251,8 @@ private static string TryMaterializeViaShellNamespace(string path, long expected string destinationFile; - if (!WaitForCopyToFinish(tempDirectory, expectedSize, cancellationToken, progress, - out destinationFile)) + if (!WaitForCopyToFinish(tempDirectory, expectedSize, expectedSizeIsExact, cancellationToken, + progress, out destinationFile)) { // The shell copy may still be writing this folder: delete it once the transfer stops // instead of blocking the worker until the device is idle. @@ -867,7 +878,7 @@ private static string TryMaterializeViaShellItem(string path, CancellationToken /// exposes the selected FolderItem and the shell can copy it into a temporary file. /// private static string TryMaterializeViaExplorerSelection(string path, long expectedSize, - CancellationToken cancellationToken, Action progress) + bool expectedSizeIsExact, CancellationToken cancellationToken, Action progress) { var tempDirectory = null as string; @@ -941,7 +952,10 @@ private static string TryMaterializeViaExplorerSelection(string path, long expec return null; if (expectedSize <= 0) + { expectedSize = ParseHumanSize(sourceSizeText); + expectedSizeIsExact = false; + } if (expectedSize > MaxMaterializedSize) return null; @@ -972,8 +986,8 @@ private static string TryMaterializeViaExplorerSelection(string path, long expec var destinationFile = Path.Combine(tempDirectory, safeName); - if (!WaitForCopyToFinish(tempDirectory, expectedSize, cancellationToken, progress, - out destinationFile)) + if (!WaitForCopyToFinish(tempDirectory, expectedSize, expectedSizeIsExact, cancellationToken, + progress, out destinationFile)) { ScheduleTempDirectoryCleanup(tempDirectory); return null; @@ -997,7 +1011,7 @@ private static string TryMaterializeViaExplorerSelection(string path, long expec /// Shell copy operations (Folder.CopyHere) are asynchronous; wait until the copied file exists and /// its size stops growing. Bounded so a stuck device copy can never hang the viewer forever. /// - private static bool WaitForCopyToFinish(string directory, long expectedSize, + private static bool WaitForCopyToFinish(string directory, long expectedSize, bool expectedSizeIsExact, CancellationToken cancellationToken, Action progress, out string resultFile) { resultFile = null; @@ -1060,7 +1074,9 @@ private static bool WaitForCopyToFinish(string directory, long expectedSize, { stableSamples++; - if (stableSamples >= 2) // ~500 ms: let the shell flush the last block + // With the exact PKEY_Size a short flush window is enough; for the rounded display + // value wait longer so a still-growing transfer is not accepted as complete. + if (stableSamples >= (expectedSizeIsExact ? 2 : 8)) return true; } else @@ -1136,7 +1152,11 @@ private static void ScheduleTempDirectoryCleanup(string tempDirectory) if (emptySamples >= 8) // no copy ever started; remove the empty directory { TryDeleteDirectory(tempDirectory); - return; + + if (!Directory.Exists(tempDirectory)) + return; + + emptySamples = 0; } } else if (size > 0 && size == lastSize) @@ -1146,7 +1166,11 @@ private static void ScheduleTempDirectoryCleanup(string tempDirectory) if (stableSamples >= 8) // ~2 s without progress: the transfer is over { TryDeleteDirectory(tempDirectory); - return; + + if (!Directory.Exists(tempDirectory)) + return; + + stableSamples = 0; } } else @@ -1158,7 +1182,14 @@ private static void ScheduleTempDirectoryCleanup(string tempDirectory) } catch (Exception) { - return; + // The shell may still hold the directory; retry until the cleanup deadline. + TryDeleteDirectory(tempDirectory); + + if (!Directory.Exists(tempDirectory)) + return; + + Thread.Sleep(250); + continue; } Thread.Sleep(250); diff --git a/QuickLook/PreparingPreview.cs b/QuickLook/PreparingPreview.cs index 21a62df99..67da4c84c 100644 --- a/QuickLook/PreparingPreview.cs +++ b/QuickLook/PreparingPreview.cs @@ -59,7 +59,10 @@ public void View(string path, ContextObject context) _panel = new InfoPanel(); context.ViewerContent = _panel; _panel.SetStatusText("Preparing preview…"); - context.IsBusy = true; + + // The placeholder panel is the content itself; a busy overlay would hide it while the transfer + // runs. The progress is reported through the panel status text instead. + context.IsBusy = false; } /// Updates the display name once the background resolution knows it. diff --git a/QuickLook/ViewWindowManager.cs b/QuickLook/ViewWindowManager.cs index 5c8e0766f..04a9687de 100644 --- a/QuickLook/ViewWindowManager.cs +++ b/QuickLook/ViewWindowManager.cs @@ -270,8 +270,8 @@ private void StartMaterialization(string shellPath, long requestId) Debug.WriteLine(ex); } - if (_materializeCts == cancellation) - _materializeCts = null; + // Only detach this worker's source: a newer worker may already own the field. + Interlocked.CompareExchange(ref _materializeCts, null, cancellation); if (token.IsCancellationRequested) { @@ -482,8 +482,6 @@ public void InvokePluginPreview(string plugin, string path = null) if (string.IsNullOrEmpty(path)) return; - CancelPendingMaterialization(); - var isDirectory = Directory.Exists(path); if (!isDirectory && !File.Exists(path)) return; @@ -501,6 +499,8 @@ public void InvokePluginPreview(string plugin, string path = null) if (matchedPlugin != null) { + // Only replace the pending preparation once a valid plugin preview is ready to be shown. + CancelPendingMaterialization(); BeginShowNewWindow(path, matchedPlugin); } } From 1bab384b769490e3ae79d833e31d53190a891a91 Mon Sep 17 00:00:00 2001 From: mohamadsm313m-prog Date: Fri, 13 Nov 2026 18:58:56 +0330 Subject: [PATCH 6/6] Remove Recycle Bin changes to separate PR The background Recycle Bin emptying is now submitted separately in its own pull request (#2069), so this PR stays focused on shell item (MTP/phone) previews. --- .../RecycleBinPanel.xaml.cs | 117 +++------- .../Translations.config | 29 --- QuickLook/App.xaml.cs | 21 -- QuickLook/Helpers/RecycleBinCleaner.cs | 205 ------------------ 4 files changed, 27 insertions(+), 345 deletions(-) delete mode 100644 QuickLook/Helpers/RecycleBinCleaner.cs diff --git a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs index 398a2c2e5..e52a85cb1 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/RecycleBinPanel.xaml.cs @@ -69,104 +69,23 @@ protected virtual void OnLoaded(object sender, RoutedEventArgs e) }); } - private Task OnEmptyRecycleBinAsync() + private async Task OnEmptyRecycleBinAsync() { - var domain = Assembly.GetExecutingAssembly().GetName().Name; - var result = MessageBox.Show( - string.Format(TranslationHelper.Get("ConfirmDeleteText", domain: domain), _info.TotalCount), - TranslationHelper.Get("RecycleBinButton", domain: domain), + string.Format(TranslationHelper.Get("ConfirmDeleteText", domain: Assembly.GetExecutingAssembly().GetName().Name), _info.TotalCount), + TranslationHelper.Get("RecycleBinButton", domain: Assembly.GetExecutingAssembly().GetName().Name), MessageBoxButton.YesNo, MessageBoxImage.Warning); - if (result != MessageBoxResult.Yes) - return Task.CompletedTask; - - // The emptying itself runs in a detached QuickLook.exe helper process, so it keeps - // running even when this viewer window is closed or another file gets previewed. - // This panel only reflects the state; it must never wait for the deletion itself. - emptyButton.IsEnabled = false; - totalSizeAndCount.Text = TranslationHelper.Get( - "RecycleBinEmptyingText", - domain: domain, - failsafe: "Emptying the Recycle Bin in the background..."); - - Process helperProcess; - try - { - helperProcess = Process.Start(new ProcessStartInfo - { - FileName = Process.GetCurrentProcess().MainModule.FileName, - Arguments = "--empty-recycle-bin", - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory, - }); - - if (helperProcess == null) - throw new InvalidOperationException("The helper process could not be started."); - } - catch (Exception ex) - { - Debug.WriteLine(ex); - - MessageBox.Show( - "QuickLook could not start the background process to empty the Recycle Bin." - + Environment.NewLine + Environment.NewLine + ex.Message, - TranslationHelper.Get("RecycleBinButton", domain: domain), - MessageBoxButton.OK, MessageBoxImage.Error); - - emptyButton.IsEnabled = true; - UpdateState(); - return Task.CompletedTask; - } - - // Never block the UI thread. Waiting is only used to refresh the panel once the - // helper is done; the window (and this plugin instance) may be gone by then. - _ = Task.Run(() => + if (result == MessageBoxResult.Yes) { - try + await Task.Run(() => { - helperProcess.WaitForExit(); - } - catch (Exception ex) - { - Debug.WriteLine(ex); - } - finally - { - try + if (RecycleBinHelper.EmptyRecycleBin()) { - helperProcess.Dispose(); + UpdateState(); } - catch (Exception ex) - { - Debug.WriteLine(ex); - } - } - - try - { - Dispatcher?.BeginInvoke(new Action(() => - { - try - { - UpdateState(); - emptyButton.IsEnabled = true; - } - catch (Exception ex) - { - Debug.WriteLine(ex); - } - })); - } - catch (Exception ex) - { - // The viewer window may have been closed while the helper was running. - Debug.WriteLine(ex); - } - }); - - return Task.CompletedTask; + }); + } } private void UpdateState() @@ -250,12 +169,23 @@ public struct SHQUERYRBINFO [DllImport("shell32.dll", CharSet = CharSet.Unicode)] private static extern int SHQueryRecycleBin(string pszRootPath, ref SHQUERYRBINFO pSHQueryRBInfo); + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHEmptyRecycleBin(nint hwnd, string pszRootPath, RecycleFlags dwFlags); + [DllImport("shell32.dll", CharSet = CharSet.Auto)] private static extern int ExtractIconEx(string lpszFile, int nIconIndex, nint[] phiconLarge, nint[] phiconSmall, int nIcons); [DllImport("user32.dll", SetLastError = true)] private static extern bool DestroyIcon(nint hIcon); + [Flags] + public enum RecycleFlags : uint + { + SHERB_NOCONFIRMATION = 0x00000001, + SHERB_NOPROGRESSUI = 0x00000002, + SHERB_NOSOUND = 0x00000004, + } + public static RecycleBinInfo GetRecycleBinInfo() { var info = new SHQUERYRBINFO() @@ -307,6 +237,13 @@ public static RecycleBinInfo GetRecycleBinInfo() return default; } + public static bool EmptyRecycleBin(RecycleFlags flags = RecycleFlags.SHERB_NOSOUND | RecycleFlags.SHERB_NOCONFIRMATION | RecycleFlags.SHERB_NOPROGRESSUI) + { + int result = SHEmptyRecycleBin(IntPtr.Zero, null, flags); + + return result == 0; // S_OK (0) + } + private static string[] GetIcons() { const string keyPath = @"CLSID\{645FF040-5081-101B-9F08-00AA002F954E}\DefaultIcon"; diff --git a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config index 41d96e5e4..6bb8817de 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config +++ b/QuickLook.Plugin/QuickLook.Plugin.CLSIDViewer/Translations.config @@ -6,202 +6,173 @@ إفراغ سلة المحذوفات هل أنت متأكد أنك تريد حذف هذه العناصر {0} نهائيًا؟ الحجم الكلي {0}، عدد العناصر {1} - يتم إفراغ سلة المحذوفات في الخلفية… La paperera està buida Buida la paperera Segur que vols eliminar definitivament aquests {0} elements? Mida total {0}, {1} elements - S'està buidant la paperera en segon pla… Ο κάδος ανακύκλωσης είναι άδειος Άδειασμα κάδου ανακύκλωσης Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτά τα {0} στοιχεία; Συνολικό μέγεθος {0}, {1} στοιχεία - Άδειασμα του κάδου ανακύκλωσης στο παρασκήνιο… סל המחזור ריק רוקן את סל המחזור האם אתה בטוח שברצונך למחוק לצמיתות את {0} פריטים אלה? גודל כולל {0}, {1} פריטים - ריקון סל המחזור ברקע… रीसायकल बिन खाली है रीसायकल बिन खाली करें क्या आप वाकई इन {0} आइटम्स को स्थायी रूप से हटाना चाहते हैं? कुल आकार {0}, {1} आइटम्स - रीसायकल बिन पृष्ठभूमि में खाली किया जा रहा है… A lomtár üres Lomtár ürítése Biztosan törölni szeretné ezt a(z) {0} elemet véglegesen? Teljes méret {0}, {1} elem - A lomtár ürítése a háttérben… Tempat Sampah kosong Kosongkan Tempat Sampah Apakah Anda yakin ingin menghapus {0} item ini secara permanen? Ukuran total {0}, {1} item - Mengosongkan Tempat Sampah di latar belakang… Il cestino è vuoto Svuota cestino Sei sicuro di voler eliminare definitivamente questi {0} elementi? Dimensione totale {0}, {1} elementi - Svuotamento del cestino in background… पुनःप्राप्ती बिन रिकामी आहे पुनःप्राप्ती बिन रिकामा करा आपण खरोखरच ही {0} आयटम्स कायमचे हटवू इच्छिता? एकूण आकार {0}, {1} आयटम्स - पुनःप्राप्ती बिन पार्श्वभूमीत रिकामी होत आहे… Papirkurven er tom Tøm papirkurven Er du sikker på at du vil slette disse {0} elementene permanent? Total størrelse {0}, {1} elementer - Tømmer papirkurven i bakgrunnen… Prullenbak is leeg Prullenbak legen Weet je zeker dat je deze {0} items permanent wilt verwijderen? Totale grootte {0}, {1} items - Prullenbak wordt op de achtergrond geleegd… Kosz jest pusty Opróżnij kosz Czy na pewno chcesz trwale usunąć te {0} elementy? Całkowity rozmiar {0}, {1} elementów - Opróżnianie kosza w tle… Lixeira está vazia Esvaziar lixeira Tem certeza de que deseja excluir permanentemente estes {0} itens? Tamanho total {0}, {1} itens - Esvaziando a Lixeira em segundo plano… A reciclagem está vazia Esvaziar reciclagem Tem a certeza que pretende eliminar permanentemente estes {0} itens? Tamanho total {0}, {1} itens - A esvaziar a Reciclagem em segundo plano… Корзина пуста Очистить корзину Вы уверены, что хотите навсегда удалить эти {0} элементов? Общий размер {0}, {1} элементов - Очистка корзины в фоновом режиме… Kôš je prázdny Vyprázdniť kôš Ste si istý, že chcete natrvalo odstrániť týchto {0} položiek? Celková veľkosť {0}, {1} položiek - Kôš sa vyprázdňuje na pozadí… Geri Dönüşüm Kutusu boş Geri Dönüşüm Kutusunu Boşalt Bu {0} öğeyi kalıcı olarak silmek istediğinizden emin misiniz? Toplam boyut {0}, {1} öğe - Geri Dönüşüm Kutusu arka planda boşaltılıyor… Кошик порожній Очистити кошик Ви впевнені, що хочете назавжди видалити ці {0} елементів? Загальний розмір {0}, {1} елементів - Очищення кошика у фоновому режимі… Thùng rác trống Làm trống thùng rác Bạn có chắc muốn xóa vĩnh viễn {0} mục này không? Tổng kích thước {0}, {1} mục - Đang dọn Thùng rác trong nền… Recycle Bin is empty Empty Recycle Bin Are you sure you want to permanently delete these {0} item(s)? Total size {0}, {1} items - Emptying the Recycle Bin in the background… 휴지통이 비어 있습니다 휴지통 비우기 {0}개 항목을 영구적으로 삭제하시겠습니까? 전체 크기 {0}, {1}개 항목 - 휴지통을 백그라운드에서 비우는 중… 回收站为空 清空回收站 您确定要永久删除这 {0} 个项目吗? 总大小 {0},共 {1} 个项目 - 正在后台清空回收站… 回收站是空的 清空回收站 您確定要永久刪除這 {0} 個項目嗎? 總大小 {0},共 {1} 個項目 - 正在背景清空資源回收筒… ごみ箱は空です ごみ箱を空にする これらの {0} 項目を完全に削除してもよろしいですか? 合計サイズ {0}、{1} 項目 - ごみ箱をバックグラウンドで空にしています… La corbeille est vide Vider la corbeille Êtes-vous sûr de vouloir supprimer définitivement ces {0} élément(s) ? Taille totale {0}, {1} éléments - Vidage de la corbeille en arrière-plan… Der Papierkorb ist leer Papierkorb leeren Möchten Sie diese {0} Elemente wirklich dauerhaft löschen? Gesamtgröße {0}, {1} Elemente - Papierkorb wird im Hintergrund geleert… La papelera está vacía Vaciar papelera ¿Está seguro de que desea eliminar permanentemente estos {0} elemento(s)? Tamaño total {0}, {1} elementos - Vaciando la papelera en segundo plano… Papperskorgen är tom Töm papperskorgen Vill du ta bort de här {0} objekten permanent? Total storlek: {0} ({1} objekt) - Tömmer papperskorgen i bakgrunden… Coșul de reciclare este gol Golire Coș de reciclare Sunteți sigur că doriți să ștergeți definitiv aceste {0} elemente? Dimensiune totală {0}, {1} elemente - Se golește coșul de reciclare în fundal… diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs index 143c30a7e..838e774e7 100644 --- a/QuickLook/App.xaml.cs +++ b/QuickLook/App.xaml.cs @@ -133,27 +133,6 @@ static IEnumerable FetchFiles(string rootPath, string targetFileName) protected override void OnStartup(StartupEventArgs e) { - // Hidden background mode: empty the Recycle Bin in a detached process, so the - // deletion keeps running after the viewer window is closed, after another file - // gets previewed, or after Space is pressed again. - // It must be handled before any instance check, tray icon or window creation. - if (e.Args.Any(arg => string.Equals(arg, "--empty-recycle-bin", StringComparison.OrdinalIgnoreCase))) - { - bool emptied = RecycleBinCleaner.EmptyRecycleBin(out int hresult); - - try - { - ProcessHelper.WriteLog($"[RecycleBinCleaner] empty result={emptied} hresult=0x{hresult:X8}"); - } - catch - { - // Logging must never change the helper's outcome. - } - - Environment.Exit(emptied ? 0 : 1); - return; - } - if (!EnsureOSVersion() || !EnsureFirstInstance(e.Args) || !EnsureFolderWritable(SettingHelper.LocalDataPath)) diff --git a/QuickLook/Helpers/RecycleBinCleaner.cs b/QuickLook/Helpers/RecycleBinCleaner.cs deleted file mode 100644 index ab20b0ee6..000000000 --- a/QuickLook/Helpers/RecycleBinCleaner.cs +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright © 2017-2026 QL-Win Contributors -// -// This file is part of QuickLook program. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Threading; - -namespace QuickLook.Helpers; - -/// -/// The actual Recycle Bin emptying routine used by the hidden `--empty-recycle-bin` -/// command line mode of QuickLook.exe. The plugin launches that mode as a detached -/// process, so emptying keeps running independently of the viewer window and of -/// whether another file gets previewed. -/// -public static class RecycleBinCleaner -{ - /// - /// Cross-process mutex which prevents two helper processes (for example, one - /// started by a movie-preview instance and one by the main instance) from - /// running a concurrent empty operation. - /// - public const string EmptyingMutexName = @"QuickLook.RecycleBin.Emptying"; - - [Flags] - private enum RecycleBinFlags : uint - { - SHERB_NOCONFIRMATION = 0x00000001, - SHERB_NOPROGRESSUI = 0x00000002, - SHERB_NOSOUND = 0x00000004, - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct SHQUERYRBINFO - { - public uint cbSize; - public ulong i64Size; - public ulong i64NumItems; - } - - [DllImport("shell32.dll", CharSet = CharSet.Unicode)] - private static extern int SHEmptyRecycleBin(IntPtr hwnd, string pszRootPath, RecycleBinFlags dwFlags); - - [DllImport("shell32.dll", CharSet = CharSet.Unicode)] - private static extern int SHQueryRecycleBin(string pszRootPath, ref SHQUERYRBINFO pSHQueryRBInfo); - - /// - /// Empties all Recycle Bin locations without any user interface. - /// - /// - /// true when the Recycle Bin was emptied (or another helper process was already - /// doing it); false when Windows reported a failure. - /// - public static bool EmptyRecycleBin() - { - return EmptyRecycleBin(out _); - } - - /// - /// Empties all Recycle Bin locations without any user interface. - /// - /// The HRESULT returned by SHEmptyRecycleBin (0 on success). - /// - /// true when the Recycle Bin was emptied (or another helper process was already - /// doing it); false when Windows reported a failure. - /// - public static bool EmptyRecycleBin(out int hresult) - { - hresult = 0; - - Mutex mutex = null; - var ownsMutex = false; - - try - { - mutex = new Mutex(false, EmptyingMutexName); - - try - { - // Do not wait: a second helper should exit immediately instead of - // starting a concurrent empty operation. - ownsMutex = mutex.WaitOne(0); - } - catch (AbandonedMutexException) - { - // The previous helper terminated while emptying. Take over. - ownsMutex = true; - } - - if (!ownsMutex) - { - // Another helper is already emptying the Recycle Bin. Wait for that operation to finish - // so this process does not report completion while the deletion is still running (the - // panel refreshes when this helper exits). - try - { - ownsMutex = mutex.WaitOne(TimeSpan.FromMinutes(10)); - } - catch (AbandonedMutexException) - { - // The other helper terminated while emptying, so the operation is over. - ownsMutex = true; - } - - if (!ownsMutex) - { - // The deletion is still running after the grace period: report a failure instead of - // claiming that the Recycle Bin is empty. - hresult = unchecked((int)0x80004005); // E_FAIL - return false; - } - - return TryGetRecycleBinInfo(out _, out var remainingItems) && remainingItems == 0; - } - - // SHEmptyRecycleBin returns E_UNEXPECTED (0x8000FFFF) when the Recycle Bin is - // already empty; that is not a failure for our background job. - if (TryGetRecycleBinInfo(out _, out var itemCount) && itemCount == 0) - { - hresult = 0; - return true; - } - - hresult = SHEmptyRecycleBin( - IntPtr.Zero, - null, - RecycleBinFlags.SHERB_NOCONFIRMATION | - RecycleBinFlags.SHERB_NOPROGRESSUI | - RecycleBinFlags.SHERB_NOSOUND); - - // S_FALSE (1) means "nothing was deleted"; both results are a success for us. - return hresult == 0 || hresult == 1; - } - catch (Exception ex) - { - Debug.WriteLine(ex); - hresult = Marshal.GetHRForException(ex); - return false; - } - finally - { - if (ownsMutex) - { - try - { - mutex.ReleaseMutex(); - } - catch (Exception ex) - { - Debug.WriteLine(ex); - } - } - - mutex?.Dispose(); - } - } - - /// - /// Queries the current Recycle Bin size and item count. - /// - /// Total size in bytes. - /// Number of items. - /// true when the query succeeded. - public static bool TryGetRecycleBinInfo(out ulong totalSize, out ulong totalCount) - { - totalSize = 0; - totalCount = 0; - - try - { - var info = new SHQUERYRBINFO - { - cbSize = (uint)Marshal.SizeOf(typeof(SHQUERYRBINFO)), - }; - - if (SHQueryRecycleBin(null, ref info) != 0) // S_OK (0) - return false; - - totalSize = info.i64Size; - totalCount = info.i64NumItems; - - return true; - } - catch (Exception ex) - { - Debug.WriteLine(ex); - return false; - } - } -}