diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index 5e9f174d..0a707007 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -2167,6 +2167,26 @@ public static string LogTabWindow_UI_Message_PluginTrustConfigurationUpdate {
}
}
+ ///
+ /// Looks up a localized string similar to Couldn't follow the shortcut, so it was opened as a file:
+ /// {0}.
+ ///
+ public static string LogTabWindow_UI_Message_UnresolvedShortcut_Single {
+ get {
+ return ResourceManager.GetString("LogTabWindow_UI_Message_UnresolvedShortcut_Single", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Couldn't follow these shortcuts, so they were opened as files:
+ /// {0}.
+ ///
+ public static string LogTabWindow_UI_Message_UnresolvedShortcut_Multiple {
+ get {
+ return ResourceManager.GetString("LogTabWindow_UI_Message_UnresolvedShortcut_Multiple", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to LogExpert session {0}.
///
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 736bc8a4..83211409 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -2093,6 +2093,14 @@ Fortfahren?
Plugin-Vertrauenskonfiguration aktualisiert.
LogExpert neu starten, um die Änderungen zu übernehmen?
+
+
+ Die Verknüpfung konnte nicht aufgelöst werden und wurde als Datei geöffnet:
+{0}
+
+
+ Die folgenden Verknüpfungen konnten nicht aufgelöst werden und wurden als Dateien geöffnet:
+{0}
{0} nicht initialisiert
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index 441e4b87..09c0793c 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -2094,6 +2094,14 @@ Continue?
Plugin trust configuration updated.
Restart LogExpert to apply changes?
+
+
+ Couldn't follow the shortcut, so it was opened as a file:
+{0}
+
+
+ Couldn't follow these shortcuts, so they were opened as files:
+{0}
Restart Recommended
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index 58f62e03..816cab37 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -1906,6 +1906,14 @@ YY[YY] = 年
插件信任配置已更新。
是否重启 LogExpert 以应用更改?
+
+
+ 无法跟随该快捷方式,已按文件打开:
+{0}
+
+
+ 无法跟随以下快捷方式,已按文件打开:
+{0}
建议重启
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 2625fb94..a954e6f7 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -205,7 +205,7 @@ public void AddFileTab_RaisesFileOpenedEvent ()
// Assert
Assert.That(receivedArgs, Is.Not.Null);
Assert.That(receivedArgs!.LogWindow, Is.SameAs(_stubLogWindow));
- Assert.That(receivedArgs.Request, Is.SameAs(request));
+ Assert.That(receivedArgs.Request, Is.EqualTo(request));
Assert.That(receivedArgs.ResolvedFileName, Is.Not.Null.And.Not.Empty);
Assert.That(receivedArgs.EncodingOptions, Is.Not.Null);
}
@@ -657,6 +657,136 @@ public void LoadFilesWithOption_SingleFile_CallsAddFileTab ()
Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("test.log"));
}
+ [Test]
+ public void LoadFilesWithOption_ShortcutToLxjFile_CallsProjectCallback ()
+ {
+ // Arrange — the .lxj check has to run on the resolved name, or the shortcut gets opened as a raw log.
+ var (directory, shortcut, session) = CreateShortcutToSession();
+
+ try
+ {
+ // Act
+ var decision = _sut.LoadFilesWithOption(new[] { shortcut }, false);
+
+ // Assert
+ Assert.That(decision, Is.EqualTo(MultiFileDecision.Cancel));
+ Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
+ Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
+ Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void AddFileTabs_ShortcutToLxjFile_CallsProjectCallback ()
+ {
+ // Arrange
+ var (directory, shortcut, session) = CreateShortcutToSession();
+
+ try
+ {
+ // Act
+ _sut.AddFileTabs(new[] { shortcut });
+
+ // Assert
+ Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
+ Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
+ Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void AddFileTab_ShortcutToLogFile_OpensTarget ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutLog");
+
+ try
+ {
+ var log = Path.Join(directory, "actual.log");
+ File.WriteAllText(log, "line1");
+
+ var shortcut = Path.Join(directory, "actual.lnk");
+ TestShortcuts.Create(shortcut, log);
+
+ // Act
+ _sut.AddFileTab(new FileTabRequest { FileName = shortcut });
+
+ // Assert
+ Assert.That(_factoryCalls, Has.Count.EqualTo(1));
+ Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo(log), "the target file is opened, not the shortcut");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void AddFileTab_UnresolvableShortcut_ReportsItInFileOpened ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutBroken");
+
+ try
+ {
+ var shortcut = Path.Join(directory, "broken.lnk");
+ TestShortcuts.Create(shortcut, Path.Join(directory, "missing.log"));
+
+ FileOpenedEventArgs? receivedArgs = null;
+ _sut.FileOpened += (_, args) => receivedArgs = args;
+
+ // Act
+ _sut.AddFileTab(new FileTabRequest { FileName = shortcut });
+
+ // Assert
+ Assert.That(receivedArgs, Is.Not.Null);
+ Assert.That(receivedArgs!.UnresolvedShortcuts, Is.EqualTo(new[] { shortcut }),
+ "the user has to be told why the shortcut opens as binary data");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void AddFileTab_ResolvedShortcut_DoesNotReportUnresolvedShortcut ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutResolved");
+
+ try
+ {
+ var log = Path.Join(directory, "actual.log");
+ File.WriteAllText(log, "line1");
+
+ var shortcut = Path.Join(directory, "actual.lnk");
+ TestShortcuts.Create(shortcut, log);
+
+ FileOpenedEventArgs? receivedArgs = null;
+ _sut.FileOpened += (_, args) => receivedArgs = args;
+
+ // Act
+ _sut.AddFileTab(new FileTabRequest { FileName = shortcut });
+
+ // Assert
+ Assert.That(receivedArgs, Is.Not.Null);
+ Assert.That(receivedArgs!.UnresolvedShortcuts, Is.Null);
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
[Test]
public void LoadFilesWithOption_SingleLxjFile_CallsProjectCallback ()
{
@@ -811,6 +941,41 @@ public void AddMultiFileTab_RaisesFileOpened_WithMultiFileNames ()
Assert.That(receivedArgs.EncodingOptions, Is.Not.Null);
}
+ [Test]
+ public void AddMultiFileTab_Shortcuts_SortsByResolvedTargetName ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutSort");
+
+ try
+ {
+ var firstTarget = Path.Join(directory, "a.log");
+ var secondTarget = Path.Join(directory, "b.log");
+ File.WriteAllText(firstTarget, "first");
+ File.WriteAllText(secondTarget, "second");
+
+ // The shortcut names sort in the opposite order of their targets.
+ var zShortcut = Path.Join(directory, "z.lnk");
+ var aShortcut = Path.Join(directory, "a.lnk");
+ TestShortcuts.Create(zShortcut, firstTarget);
+ TestShortcuts.Create(aShortcut, secondTarget);
+
+ string[]? multiFileNames = null;
+ _sut.FileOpened += (_, e) => multiFileNames = e.MultiFileNames;
+
+ // Act
+ _ = _sut.AddMultiFileTab([zShortcut, aShortcut]);
+
+ // Assert
+ Assert.That(multiFileNames, Is.EqualTo(new[] { firstTarget, secondTarget }),
+ "multi-file order must follow the resolved target names, not the shortcut names");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
[Test]
public void AddFileTabs_SkipsEmptyStrings ()
{
@@ -1225,6 +1390,23 @@ public void LoadFiles_DelegatesToAddFileTabs ()
Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("b.log"));
}
+ ///
+ /// Creates a temporary directory holding a session file and a shortcut to it, and returns the
+ /// three paths. The caller owns the directory and deletes it.
+ ///
+ private static (string Directory, string Shortcut, string Session) CreateShortcutToSession ()
+ {
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutSession");
+
+ var session = Path.Join(directory, "project.lxj");
+ File.WriteAllText(session, "");
+
+ var shortcut = Path.Join(directory, "session.lnk");
+ TestShortcuts.Create(shortcut, session);
+
+ return (directory, shortcut, session);
+ }
+
///
/// Builds a second service on the shared config manager, standing in for another LogTabWindow
/// of the same session with its own tab controller.
diff --git a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
new file mode 100644
index 00000000..14ea7d66
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
@@ -0,0 +1,225 @@
+using System.Runtime.Versioning;
+
+using LogExpert.UI.Services.FileOperationService;
+
+using NUnit.Framework;
+
+namespace LogExpert.UI.Tests.Services;
+
+[TestFixture]
+[SupportedOSPlatform("windows")]
+internal class ShortcutResolverTests
+{
+ [Test]
+ public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutLocal");
+
+ try
+ {
+ var log = Path.Join(directory, "actual.log");
+ File.WriteAllText(log, "line1");
+
+ var shortcut = Path.Join(directory, "actual.lnk");
+ TestShortcuts.Create(shortcut, log);
+
+ // Act
+ var resolved = ShortcutResolver.Resolve(shortcut);
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(log));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutUnc");
+
+ try
+ {
+ var shortcut = Path.Join(directory, "remote.lnk");
+ TestShortcuts.Create(shortcut, @"\\nonexistent-host\share\actual.log");
+
+ // Act
+ var resolved = ShortcutResolver.Resolve(shortcut);
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(shortcut),
+ "a UNC target must not be probed: File.Exists would authenticate against the remote host");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutOversized");
+
+ try
+ {
+ var shortcut = Path.Join(directory, "oversized.lnk");
+ File.WriteAllBytes(shortcut, new byte[ShortcutResolver.MAX_SHORTCUT_SIZE + 1]);
+
+ // Act
+ var resolved = ShortcutResolver.Resolve(shortcut);
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(shortcut));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ // The shell resolves .lnk targets when saving, so a real chain cannot be produced with
+ // WScript.Shell; the follower is exercised with injected hops instead.
+ [Test]
+ public void FollowChain_ShortcutToShortcut_ReturnsFinalTarget ()
+ {
+ // Arrange
+ var targets = new Dictionary
+ {
+ [@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
+ [@"C:\logs\second.lnk"] = @"C:\logs\actual.log"
+ };
+
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\actual.log"));
+ }
+
+ [Test]
+ public void FollowChain_ChainAtLimit_ReturnsFinalTarget ()
+ {
+ // Arrange
+ var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS);
+
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\actual.log"));
+ }
+
+ [Test]
+ public void FollowChain_ChainBeyondLimit_StopsAfterMaxHops ()
+ {
+ // Arrange
+ var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS + 1);
+
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\link1.lnk"));
+ }
+
+ [Test]
+ public void FollowChain_CyclicChain_StopsAfterMaxHops ()
+ {
+ // Arrange
+ var targets = new Dictionary
+ {
+ [@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
+ [@"C:\logs\second.lnk"] = @"C:\logs\first.lnk"
+ };
+
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\first.lnk"));
+ }
+
+ [TestCase("//nonexistent-host/share/actual.log")]
+ [TestCase(@"\\?\UNC\nonexistent-host\share\actual.log")]
+ [TestCase(@"\\?\C:\Windows\system32\drivers\etc\hosts")]
+ [TestCase(@"\\.\C:\nonexistent\actual.log")]
+ [TestCase(@"\??\UNC\nonexistent-host\share\actual.log")]
+ public void SanitizeLocalTarget_DisguisedNonLocalTarget_IsRejected (string target)
+ {
+ // Arrange
+ const string shortcutDirectory = @"C:\logs";
+
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(target, shortcutDirectory);
+
+ // Assert
+ Assert.That(sanitized, Is.Null,
+ $"'{target}' must not be followed: probing it could reach the network");
+ }
+
+ [Test]
+ public void SanitizeLocalTarget_PlainLocalTarget_ReturnsFullPath ()
+ {
+ // Arrange
+ const string target = @"C:\logs\actual.log";
+
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(target, @"D:\other");
+
+ // Assert
+ Assert.That(sanitized, Is.EqualTo(target));
+ }
+
+ [Test]
+ public void SanitizeLocalTarget_RelativeTarget_ResolvesAgainstShortcutDirectory ()
+ {
+ // Arrange
+ const string target = @"sub\actual.log";
+
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(target, @"C:\logs");
+
+ // Assert
+ Assert.That(sanitized, Is.EqualTo(@"C:\logs\sub\actual.log"));
+ }
+
+ [Test]
+ public void SanitizeLocalTarget_EnvironmentVariableTarget_IsExpanded ()
+ {
+ // Arrange
+ Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", @"C:\logs", EnvironmentVariableTarget.Process);
+
+ try
+ {
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(@"%LOGEXPERT_TEST_VAR%\actual.log", @"D:\other");
+
+ // Assert
+ Assert.That(sanitized, Is.EqualTo(@"C:\logs\actual.log"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", null, EnvironmentVariableTarget.Process);
+ }
+ }
+
+ private static Dictionary CreateChain (int linkCount)
+ {
+ var targets = new Dictionary();
+
+ for (var i = 1; i <= linkCount; i++)
+ {
+ targets[$@"C:\logs\link{i}.lnk"] = i == linkCount
+ ? @"C:\logs\actual.log"
+ : $@"C:\logs\link{i + 1}.lnk";
+ }
+
+ return targets;
+ }
+}
diff --git a/src/LogExpert.UI.Tests/Services/TestShortcuts.cs b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
new file mode 100644
index 00000000..8b2cd218
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
@@ -0,0 +1,41 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+using NUnit.Framework;
+
+namespace LogExpert.UI.Tests.Services;
+
+[SupportedOSPlatform("windows")]
+internal static class TestShortcuts
+{
+ public static string CreateTempDirectory (string name)
+ {
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ public static void Create (string shortcutPath, string targetPath)
+ {
+ var shellType = Type.GetTypeFromProgID("WScript.Shell");
+ Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");
+
+ dynamic shell = Activator.CreateInstance(shellType)!;
+ dynamic? link = null;
+ try
+ {
+ link = shell.CreateShortcut(shortcutPath);
+ link.TargetPath = targetPath;
+ link.Save();
+ }
+ finally
+ {
+ if (link != null)
+ {
+ Marshal.FinalReleaseComObject(link);
+ }
+
+ Marshal.FinalReleaseComObject(shell);
+ }
+ }
+}
diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
index 3b1218b3..e76c7394 100644
--- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
+++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
@@ -80,6 +80,12 @@ internal partial class LogTabWindow : Form, ILogTabWindow
private bool _wasMaximized;
+ // Unfollowable shortcuts are reported once per batch and once per shortcut, so dropping a
+ // folder of broken links does not stack message boxes.
+ private readonly HashSet _reportedUnresolvedShortcuts = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _pendingUnresolvedShortcuts = [];
+ private bool _unresolvedShortcutMessageQueued;
+
#endregion
#region cTor
@@ -230,6 +236,65 @@ private void OnFileOperationServiceFileOpened (object? sender, FileOpenedEventAr
multiFileEnabledStripMenuItem.Checked = true;
_ = BeginInvoke(e.LogWindow.LoadFilesAsMulti, e.MultiFileNames, e.EncodingOptions);
}
+
+ if (e.UnresolvedShortcuts is { Length: > 0 })
+ {
+ QueueUnresolvedShortcutMessage(e.UnresolvedShortcuts);
+ }
+ }
+
+ private void QueueUnresolvedShortcutMessage (string[] shortcuts)
+ {
+ foreach (var shortcut in shortcuts)
+ {
+ if (!_reportedUnresolvedShortcuts.Contains(shortcut)
+ && !_pendingUnresolvedShortcuts.Contains(shortcut, StringComparer.OrdinalIgnoreCase))
+ {
+ _pendingUnresolvedShortcuts.Add(shortcut);
+ }
+ }
+
+ if (_pendingUnresolvedShortcuts.Count == 0 || _unresolvedShortcutMessageQueued || _disposed)
+ {
+ return;
+ }
+
+ _unresolvedShortcutMessageQueued = true;
+
+ if (IsHandleCreated)
+ {
+ _ = BeginInvoke(new MethodInvoker(ShowUnresolvedShortcutMessage));
+ }
+ else
+ {
+ ShowUnresolvedShortcutMessage();
+ }
+ }
+
+ private void ShowUnresolvedShortcutMessage ()
+ {
+ _unresolvedShortcutMessageQueued = false;
+
+ var shortcuts = _pendingUnresolvedShortcuts.ToArray();
+ _pendingUnresolvedShortcuts.Clear();
+
+ if (shortcuts.Length == 0 || _disposed)
+ {
+ return;
+ }
+
+ foreach (var shortcut in shortcuts)
+ {
+ _reportedUnresolvedShortcuts.Add(shortcut);
+ }
+
+ var list = string.Join(Environment.NewLine, shortcuts.Select(shortcut => $" • {shortcut}"));
+ var format = shortcuts.Length == 1
+ ? Resources.LogTabWindow_UI_Message_UnresolvedShortcut_Single
+ : Resources.LogTabWindow_UI_Message_UnresolvedShortcut_Multiple;
+
+ _ = MessageBox.Show(this, string.Format(CultureInfo.CurrentCulture, format, list),
+ Resources.LogExpert_Common_UI_Title_LogExpert, MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
private void InitializeMenuToolbarControllerEvents ()
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs b/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs
index dd8b0491..cc840764 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs
@@ -33,4 +33,10 @@ internal sealed class FileOpenedEventArgs : EventArgs
/// Set when AddMultiFileTab created this tab. LogTabWindow uses this for BeginInvoke(LoadFilesAsMulti).
public string[]? MultiFileNames { get; init; }
+
+ ///
+ /// Shortcuts that could not be followed and are opened as plain files. LogTabWindow tells the
+ /// user, because such a file opens as binary content with only a log line otherwise.
+ ///
+ public string[]? UnresolvedShortcuts { get; init; }
}
\ No newline at end of file
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 0c72551c..b1e7b5ef 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -67,6 +67,11 @@ internal sealed class FileOperationService (
// //}
public LogWindow AddFileTab (FileTabRequest request)
+ {
+ return AddResolvedFileTab(request with { FileName = ShortcutResolver.Resolve(request.FileName) });
+ }
+
+ private LogWindow AddResolvedFileTab (FileTabRequest request)
{
var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry);
var normalizedPath = Path.GetFullPath(logFileName);
@@ -120,7 +125,8 @@ public LogWindow AddFileTab (FileTabRequest request)
LogWindow = logWindow,
Request = request,
ResolvedFileName = logFileName,
- EncodingOptions = encodingOptions
+ EncodingOptions = encodingOptions,
+ UnresolvedShortcuts = ShortcutResolver.IsShortcut(request.FileName) ? [request.FileName] : null
});
return logWindow;
@@ -199,10 +205,17 @@ public LogWindow AddTempFileTab (string fileName, string title)
public LogWindow? AddMultiFileTab (string[] fileNames)
{
- if (fileNames.Length < 1)
- {
- return null;
- }
+ return fileNames.Length < 1
+ ? null
+ : AddResolvedMultiFileTab(fileNames.Select(ShortcutResolver.Resolve).ToArray());
+ }
+
+ private LogWindow? AddResolvedMultiFileTab (string[] fileNames)
+ {
+ // Shortcuts are already resolved by the caller. Sorting here keeps multi-file order by
+ // target name on every entry path (drop, command line, Ask → MultiFile, ...) instead of
+ // letting it drift back to shortcut names.
+ Array.Sort(fileNames, StringComparer.OrdinalIgnoreCase);
// Session files (.lxj) aren't logs — dispatch them to the project loader and
// continue with only the log files. Without this, the .lxj would be treated as
@@ -212,7 +225,7 @@ public LogWindow AddTempFileTab (string fileName, string title)
AddFileTabs(fileNames.Where(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray());
var logs = fileNames.Where(f => !f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray();
- return logs.Length > 0 ? AddMultiFileTab(logs) : null;
+ return logs.Length > 0 ? AddResolvedMultiFileTab(logs) : null;
}
EncodingOptions encodingOptions = new();
@@ -231,13 +244,16 @@ public LogWindow AddTempFileTab (string fileName, string title)
// We raise FileOpened with MultiFileNames so LogTabWindow can call BeginInvoke(logWindow.LoadFilesAsMulti, ...).
AddToFileHistory(fileNames[0]);
+ var unresolvedShortcuts = fileNames.Where(ShortcutResolver.IsShortcut).ToArray();
+
FileOpened?.Invoke(this, new FileOpenedEventArgs
{
LogWindow = logWindow,
Request = request,
ResolvedFileName = fileNames[^1],
EncodingOptions = encodingOptions,
- MultiFileNames = fileNames
+ MultiFileNames = fileNames,
+ UnresolvedShortcuts = unresolvedShortcuts.Length > 0 ? unresolvedShortcuts : null
});
return logWindow;
@@ -270,13 +286,13 @@ public MultiFileDecision LoadFilesWithOption (string[] fileNames, bool invertLog
if (fileNames.Length == 1)
{
- if (fileNames[0].EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
+ var logFile = ResolveLogFile(fileNames[0], true);
+ if (logFile == null)
{
- _loadSessionCallback(fileNames[0], true);
return MultiFileDecision.Cancel; // Already handled
}
- _ = AddFileTab(new FileTabRequest { FileName = fileNames[0] });
+ _ = AddResolvedFileTab(new FileTabRequest { FileName = logFile });
return MultiFileDecision.SingleFiles;
}
@@ -310,14 +326,13 @@ public void AddFileTabs (string[] fileNames, int? targetLine = null)
{
try
{
- if (fileName.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
- {
- _loadSessionCallback(fileName, false);
- }
- else
+ var logFile = ResolveLogFile(fileName, false);
+ if (logFile == null)
{
- _ = AddFileTab(new FileTabRequest { FileName = fileName, TargetLine = targetLine });
+ continue;
}
+
+ _ = AddResolvedFileTab(new FileTabRequest { FileName = logFile, TargetLine = targetLine });
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or LogFileException)
{
@@ -326,6 +341,18 @@ public void AddFileTabs (string[] fileNames, int? targetLine = null)
}
}
+ private string? ResolveLogFile (string fileName, bool asFirstFile)
+ {
+ var resolved = ShortcutResolver.Resolve(fileName);
+ if (resolved.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
+ {
+ _loadSessionCallback(resolved, asFirstFile);
+ return null;
+ }
+
+ return resolved;
+ }
+
public LogWindow? PasteFromClipboard ()
{
var text = _clipboardTextProvider();
diff --git a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
new file mode 100644
index 00000000..7235fd84
--- /dev/null
+++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
@@ -0,0 +1,178 @@
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.ComTypes;
+using System.Runtime.Versioning;
+using System.Text;
+
+using NLog;
+
+using IShellLinkW = Vanara.PInvoke.Shell32.IShellLinkW;
+using CShellLinkW = Vanara.PInvoke.Shell32.CShellLinkW;
+using SLGP = Vanara.PInvoke.Shell32.SLGP;
+using STGM = Vanara.PInvoke.STGM;
+
+namespace LogExpert.UI.Services.FileOperationService;
+
+[SupportedOSPlatform("windows")]
+internal static class ShortcutResolver
+{
+ #region Constants
+
+ private const string SHORTCUT_EXTENSION = ".lnk";
+
+ private const int TARGET_PATH_CAPACITY = 1024;
+
+ internal const int MAX_SHORTCUT_SIZE = 1024 * 1024;
+
+ // A chain is followed to its end, but a broken or cyclic chain must not load COM objects forever.
+ internal const int MAX_SHORTCUT_HOPS = 3;
+
+ #endregion
+
+ #region Fields
+
+ private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
+
+ #endregion
+
+ #region Public methods
+
+ /// Follows through at most shortcuts.
+ ///
+ /// The final existing target; otherwise the last path that could not be followed (the input
+ /// itself when the hop limit is reached).
+ ///
+ public static string Resolve (string fileName)
+ {
+ return FollowChain(fileName, ResolveOneHop);
+ }
+
+ #endregion
+
+ #region Internal methods
+
+ internal static string FollowChain (string fileName, Func resolveOneHop)
+ {
+ var current = fileName;
+
+ for (var hop = 0; ; hop++)
+ {
+ if (!IsShortcut(current))
+ {
+ return current;
+ }
+
+ if (hop >= MAX_SHORTCUT_HOPS)
+ {
+ _logger.Warn("Shortcut chain starting at {Shortcut} exceeds {Limit} hops; keeping it", fileName, MAX_SHORTCUT_HOPS);
+ return fileName;
+ }
+
+ var target = resolveOneHop(current);
+ if (target is null)
+ {
+ return current;
+ }
+
+ current = target;
+ }
+ }
+
+ internal static string? SanitizeLocalTarget (string target, string shortcutDirectory)
+ {
+ try
+ {
+ var expanded = Environment.ExpandEnvironmentVariables(target);
+ var fullTarget = Path.GetFullPath(expanded, shortcutDirectory);
+
+ // Mapped drives are allowed on purpose: the user created the mapping, so it is trusted.
+ var root = Path.GetPathRoot(fullTarget);
+ return root is not null && root.Length == 3 && root[1] == ':' ? fullTarget : null;
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or IOException
+ or NotSupportedException)
+ {
+ return null;
+ }
+ }
+
+ #endregion
+
+ #region Private methods
+
+ internal static bool IsShortcut (string fileName)
+ {
+ return !string.IsNullOrWhiteSpace(fileName)
+ && Path.GetExtension(fileName).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string? ResolveOneHop (string fileName)
+ {
+ IShellLinkW? shellLink = null;
+
+ try
+ {
+ if (!File.Exists(fileName))
+ {
+ _logger.Debug("Shortcut {Shortcut} does not exist, keeping it", fileName);
+ return null;
+ }
+
+ if (new FileInfo(fileName).Length > MAX_SHORTCUT_SIZE)
+ {
+ _logger.Warn("Ignoring {Shortcut}: it is above the {Limit} byte shortcut limit", fileName, MAX_SHORTCUT_SIZE);
+ return null;
+ }
+
+ shellLink = (IShellLinkW)new CShellLinkW();
+ ((IPersistFile)shellLink).Load(fileName, (int)STGM.STGM_READ);
+
+ var buffer = new StringBuilder(TARGET_PATH_CAPACITY);
+ shellLink.GetPath(buffer, buffer.Capacity, out _, SLGP.SLGP_RAWPATH);
+
+ var target = buffer.ToString();
+ if (target.Length == 0)
+ {
+ _logger.Debug("Shortcut {Shortcut} has no target path, keeping it", fileName);
+ return null;
+ }
+
+ var shortcutDirectory = Path.GetDirectoryName(Path.GetFullPath(fileName))!;
+
+ // The target comes straight from the .lnk bytes, so only follow a plain local drive path:
+ // probing a UNC/device path would authenticate against a remote host (NetNTLM leak).
+ var fullTarget = SanitizeLocalTarget(target, shortcutDirectory);
+ if (fullTarget is null)
+ {
+ _logger.Debug("Shortcut target {Target} is not a local drive path, keeping {Shortcut}", target, fileName);
+ return null;
+ }
+
+ if (!File.Exists(fullTarget))
+ {
+ _logger.Debug("Shortcut target {Target} does not exist, keeping {Shortcut}", target, fileName);
+ return null;
+ }
+
+ return fullTarget;
+ }
+ catch (Exception ex) when (ex is ExternalException
+ or IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException)
+ {
+ _logger.Debug(ex, "Could not resolve shortcut {Shortcut}, keeping it", fileName);
+ return null;
+ }
+ finally
+ {
+ if (shellLink is not null)
+ {
+ Marshal.FinalReleaseComObject(shellLink);
+ }
+ }
+ }
+
+ #endregion
+}