diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs
index 838e774e7..6e618d197 100644
--- a/QuickLook/App.xaml.cs
+++ b/QuickLook/App.xaml.cs
@@ -54,6 +54,17 @@ public partial class App : Application
static App()
{
+ // Compile what the previous run used at startup on spare cores, in parallel with the startup itself
+ try
+ {
+ System.Runtime.ProfileOptimization.SetProfileRoot(SettingHelper.LocalDataPath);
+ System.Runtime.ProfileOptimization.StartProfile("QuickLook.Startup.jitprofile");
+ }
+ catch
+ {
+ // data folder not writable yet: EnsureFolderWritable reports it
+ }
+
// Set the process render mode based on the user setting
var processRenderMode = SettingHelper.Get("ProcessRenderMode", failsafe: (int)RenderMode.Default, "QuickLook");
if (processRenderMode == (int)RenderMode.SoftwareOnly)
@@ -359,9 +370,15 @@ private void RunListener(StartupEventArgs e)
NativeMethods.QuickLook.Init();
+ var sw = Stopwatch.StartNew();
PluginManager.GetInstance();
+ var pluginsMs = sw.ElapsedMilliseconds;
+ sw.Restart();
ViewWindowManager.GetInstance();
+ KeyboardLog.Write($"Startup: plugins {pluginsMs} ms, viewer window {sw.ElapsedMilliseconds} ms, since process start {(DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds:F0} ms");
KeystrokeDispatcher.GetInstance();
PipeServerManager.GetInstance();
+
+ PluginManager.GetInstance().WarmUp();
}
}
diff --git a/QuickLook/Helpers/KeyboardLog.cs b/QuickLook/Helpers/KeyboardLog.cs
new file mode 100644
index 000000000..b92d2d510
--- /dev/null
+++ b/QuickLook/Helpers/KeyboardLog.cs
@@ -0,0 +1,102 @@
+// 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.Helpers;
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+namespace QuickLook.Helpers;
+
+///
+/// Diagnostic log for the keyboard pipeline, written to QuickLook.Keyboard.log in the data folder.
+/// Only QuickLook's own keys and anomalies are logged, never other keystrokes.
+///
+internal static class KeyboardLog
+{
+ private const long MAX_SIZE = 512 * 1024;
+
+ /// Preview phases slower than this block switching to the next file and get logged.
+ private const long SLOW_PREVIEW_PHASE_MS = 100;
+
+ private static readonly string LogPath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Keyboard.log");
+
+ // Bounded so that a stuck disk can never make the keyboard hook wait or grow memory.
+ private static readonly BlockingCollection Queue = new(new ConcurrentQueue(), 1000);
+
+ static KeyboardLog()
+ {
+ new Thread(WriteLoop)
+ {
+ IsBackground = true,
+ Name = "QuickLook.KeyboardLog",
+ Priority = ThreadPriority.BelowNormal,
+ }.Start();
+ }
+
+ ///
+ /// Never blocks; safe to call from inside the low-level keyboard hook.
+ ///
+ public static void Write(string message)
+ {
+ Queue.TryAdd($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [T{Thread.CurrentThread.ManagedThreadId}] {message}");
+ }
+
+ ///
+ /// Logs a preview phase that ran on the UI thread only when it was slow enough to delay
+ /// the next selection change, naming the plugin and file type, never the file name.
+ ///
+ public static void WriteIfSlow(string phase, long elapsedMs)
+ {
+ if (elapsedMs >= SLOW_PREVIEW_PHASE_MS)
+ Write($"Slow preview {phase}: {elapsedMs} ms");
+ }
+
+ private static void WriteLoop()
+ {
+ var batch = new StringBuilder();
+
+ foreach (var line in Queue.GetConsumingEnumerable())
+ {
+ batch.AppendLine(line);
+ while (Queue.TryTake(out var more))
+ batch.AppendLine(more);
+
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
+
+ var file = new FileInfo(LogPath);
+ if (file.Exists && file.Length > MAX_SIZE)
+ {
+ File.Delete(LogPath + ".old");
+ File.Move(LogPath, LogPath + ".old");
+ }
+
+ File.AppendAllText(LogPath, batch.ToString());
+ }
+ catch
+ {
+ // logging must never take QuickLook down
+ }
+
+ batch.Clear();
+ }
+ }
+}
diff --git a/QuickLook/PluginManager.cs b/QuickLook/PluginManager.cs
index 7560dcc6e..db8c0bbe8 100644
--- a/QuickLook/PluginManager.cs
+++ b/QuickLook/PluginManager.cs
@@ -18,13 +18,18 @@
using QuickLook.Common.ExtensionMethods;
using QuickLook.Common.Helpers;
using QuickLook.Common.Plugin;
+using QuickLook.Helpers;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
using System.Windows;
+using System.Windows.Threading;
using UnblockZoneIdentifier;
namespace QuickLook;
@@ -35,9 +40,13 @@ public sealed class PluginManager
private PluginManager()
{
+ var sw = Stopwatch.StartNew();
LoadPlugins(App.UserPluginPath);
LoadPlugins(Path.Combine(App.AppPath, @"QuickLook.Plugin\"));
+ var loadMs = sw.ElapsedMilliseconds;
+ sw.Restart();
InitLoadedPlugins();
+ KeyboardLog.Write($"Startup: plugins loaded in {loadMs} ms, initialized in {sw.ElapsedMilliseconds} ms");
}
internal IViewer DefaultPlugin { get; } = new Plugin.InfoPanel.Plugin();
@@ -79,6 +88,104 @@ internal IViewer FindMatch(string path)
return (matched ?? DefaultPlugin).GetType().CreateInstance();
}
+ ///
+ /// Pays the first-use costs (JIT, static constructors, dependent assemblies) at startup instead of
+ /// on the first preview. Each plugin's CanHandle runs once on the UI thread at idle priority, one plugin
+ /// per dispatcher operation so a key press is never held up; then every plugin assembly is compiled
+ /// ahead on a low-priority background thread, which also covers Prepare and View.
+ ///
+ internal void WarmUp()
+ {
+ var sw = Stopwatch.StartNew();
+ var probe = Path.Combine(Path.GetTempPath(), "QuickLook.WarmUp.nonexistent.txt");
+
+ foreach (var plugin in LoadedPlugins)
+ {
+ Application.Current.Dispatcher.BeginInvoke(() =>
+ {
+ try
+ {
+ plugin.CanHandle(probe);
+ }
+ catch
+ {
+ // ignored
+ }
+ }, DispatcherPriority.ApplicationIdle);
+ }
+
+ Application.Current.Dispatcher.BeginInvoke(() =>
+ {
+ KeyboardLog.Write($"Startup: CanHandle warm-up done in {sw.ElapsedMilliseconds} ms");
+
+ var assemblies = LoadedPlugins.Select(p => p.GetType().Assembly).Distinct().ToArray();
+ new Thread(() => PrepareAssemblies(assemblies))
+ {
+ IsBackground = true,
+ Name = "QuickLook.WarmUp",
+ Priority = ThreadPriority.Lowest,
+ }.Start();
+ }, DispatcherPriority.ApplicationIdle);
+ }
+
+ private static void PrepareAssemblies(Assembly[] assemblies)
+ {
+ var sw = Stopwatch.StartNew();
+ var count = 0;
+
+ foreach (var assembly in assemblies)
+ {
+ Type[] types;
+ try
+ {
+ types = assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException e)
+ {
+ types = [.. e.Types.Where(t => t != null)];
+ }
+
+ foreach (var type in types)
+ {
+ if (type.IsGenericTypeDefinition || type.IsInterface)
+ continue;
+
+ const BindingFlags flags = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Static
+ | BindingFlags.Public | BindingFlags.NonPublic;
+
+ MethodBase[] methods;
+ try
+ {
+ methods = [.. type.GetMethods(flags), .. type.GetConstructors(flags)];
+ }
+ catch
+ {
+ continue;
+ }
+
+ foreach (var method in methods)
+ {
+ if (method.IsAbstract || method.ContainsGenericParameters
+ || (method.MethodImplementationFlags & MethodImplAttributes.InternalCall) != 0
+ || method.GetMethodBody() == null)
+ continue;
+
+ try
+ {
+ RuntimeHelpers.PrepareMethod(method.MethodHandle);
+ count++;
+ }
+ catch
+ {
+ // a missing optional dependency, etc.: that method is simply compiled on first use
+ }
+ }
+ }
+ }
+
+ KeyboardLog.Write($"Startup: precompiled {count} plugin methods in {sw.ElapsedMilliseconds} ms");
+ }
+
private void LoadPlugins(string folder)
{
if (!Directory.Exists(folder))
@@ -90,6 +197,7 @@ private void LoadPlugins(string folder)
.ToList()
.ForEach(lib =>
{
+ var sw = Stopwatch.StartNew();
try
{
Assembly.LoadFrom(lib)
@@ -110,6 +218,7 @@ private void LoadPlugins(string folder)
ProcessHelper.WriteLog($"Failed to load plugin {Path.GetFileName(lib)}: {ex}");
failedPlugins.Add((Path.GetFileName(lib), ex));
}
+ KeyboardLog.WriteIfSlow($"startup load {Path.GetFileNameWithoutExtension(lib)}", sw.ElapsedMilliseconds);
});
LoadedPlugins = [.. LoadedPlugins.OrderByDescending(i => i.Priority)];
@@ -132,6 +241,7 @@ private void InitLoadedPlugins()
{
LoadedPlugins.ForEach(i =>
{
+ var sw = Stopwatch.StartNew();
try
{
i.Init();
@@ -140,6 +250,7 @@ private void InitLoadedPlugins()
{
ProcessHelper.WriteLog(e.ToString());
}
+ KeyboardLog.WriteIfSlow($"startup init {i.GetType().Namespace}", sw.ElapsedMilliseconds);
});
}