diff --git a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
index 24f7894cd..965daf471 100644
--- a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
+++ b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
@@ -10,8 +10,8 @@ public static class PersisterHelpers
private const string LOCAL_FILE_SYSTEM_NAME = "LocalFileSystem";
///
- /// Checks if the file name is a settings file (.lxp). If so, the contained logfile name
- /// is returned. If not, the given file name is returned unchanged.
+ /// Returns the log file named by a settings file (.lxp), or unchanged
+ /// when it is not one.
///
/// The file name to resolve
/// Plugin registry for file system resolution (optional)
diff --git a/src/LogExpert.Core/Helpers/ShellLinkResolver.cs b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs
new file mode 100644
index 000000000..ff4f74d59
--- /dev/null
+++ b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs
@@ -0,0 +1,566 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Text;
+
+using NLog;
+
+namespace LogExpert.Core.Helpers;
+
+///
+/// Resolves a Windows shortcut (.lnk) to the local file it points at.
+///
+///
+/// The MS-SHLLINK format is parsed directly rather than through IShellLink, because this
+/// lives in the Core assembly, which carries no COM. Only local targets are followed: UNC and
+/// device paths are skipped, and anything unresolvable is returned unchanged.
+///
+public static class ShellLinkResolver
+{
+ #region Constants
+
+ private const string SHORTCUT_EXTENSION = ".lnk";
+
+ /// Size of ShellLinkHeader, and the value of its first field.
+ private const int SHELL_LINK_HEADER_SIZE = 0x4C;
+
+ /// Offset of LinkFlags: right after the header size and the link CLSID.
+ private const int LINK_FLAGS_OFFSET = 20;
+
+ /// Size of LinkInfo without the optional Unicode offsets.
+ private const int LINK_INFO_MIN_SIZE = 0x1C;
+
+ /// LinkInfoHeaderSize of a LinkInfo that carries the Unicode offsets.
+ private const int LINK_INFO_HEADER_SIZE_UNICODE = 0x24;
+
+ ///
+ /// Real shortcuts are a few KB. Anything larger is not one, and reading it whole would let a
+ /// dropped file exhaust memory.
+ ///
+ private const int MAX_SHORTCUT_SIZE = 1024 * 1024;
+
+ ///
+ /// Upper bound for chained shortcuts. A shortcut pointing at a shortcut is legitimate; longer
+ /// chains are either pathological or a loop.
+ ///
+ private const int MAX_RESOLVE_DEPTH = 8;
+
+ // LinkFlags
+ private const uint HAS_LINK_TARGET_ID_LIST = 0x00000001;
+ private const uint HAS_LINK_INFO = 0x00000002;
+ private const uint HAS_NAME = 0x00000004;
+ private const uint HAS_RELATIVE_PATH = 0x00000008;
+ private const uint IS_UNICODE = 0x00000080;
+
+ // LinkInfoFlags
+ private const uint VOLUME_ID_AND_LOCAL_BASE_PATH = 0x00000001;
+
+ ///
+ /// CLSID of a Shell Link: 00021401-0000-0000-C000-000000000046, in its on-disk byte order.
+ ///
+ private static ReadOnlySpan ShellLinkClsid =>
+ [
+ 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46
+ ];
+
+ #endregion
+
+ #region Fields
+
+ private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
+
+ ///
+ /// Encoding of the ANSI strings. Resolved lazily and only for shortcuts that are not flagged
+ /// Unicode; Latin1 is a sufficient fallback where no code pages are available.
+ ///
+ private static readonly Lazy _ansiEncoding = new(ResolveAnsiEncoding);
+
+ #endregion
+
+ #region Public methods
+
+ ///
+ /// Resolves to the file it points at, following chained shortcuts.
+ ///
+ /// A path that may or may not be a shortcut.
+ ///
+ /// The target path when the whole chain resolves to an existing local file; otherwise
+ /// unchanged.
+ ///
+ public static string Resolve (string fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return fileName;
+ }
+
+ var current = fileName;
+
+ for (var depth = 0; depth < MAX_RESOLVE_DEPTH; depth++)
+ {
+ if (!IsShortcutFile(current))
+ {
+ return current;
+ }
+
+ var target = TryResolveSingle(current);
+ if (target is null)
+ {
+ // Keep what the caller passed in, not a half-followed chain, so a broken second link
+ // behaves exactly like a broken first one.
+ _logger.Debug("Shortcut {Shortcut} is unresolvable, keeping {Original}", current, fileName);
+ return fileName;
+ }
+
+ current = target;
+ }
+
+ _logger.Warn("Shortcut chain starting at {Shortcut} exceeds {Depth} links, keeping the original path", fileName, MAX_RESOLVE_DEPTH);
+ return fileName;
+ }
+
+ #endregion
+
+ #region Internal parsing core
+
+ ///
+ /// Parses the path fields out of shortcut bytes. Separate from and free of
+ /// I/O so tests can feed crafted shortcuts in.
+ ///
+ internal static bool TryParseTargets (ReadOnlySpan data, out ShortcutTargets targets)
+ {
+ targets = new ShortcutTargets();
+
+ if (!IsShellLink(data))
+ {
+ return false;
+ }
+
+ var flags = BinaryPrimitives.ReadUInt32LittleEndian(data[LINK_FLAGS_OFFSET..]);
+ var isUnicode = (flags & IS_UNICODE) != 0;
+ var position = SHELL_LINK_HEADER_SIZE;
+
+ if ((flags & HAS_LINK_TARGET_ID_LIST) != 0 && !TrySkipIdList(data, ref position))
+ {
+ return false;
+ }
+
+ if ((flags & HAS_LINK_INFO) != 0 && !TryReadLinkInfo(data, ref position, targets))
+ {
+ return false;
+ }
+
+ // StringData follows LinkInfo, one entry per flag, in a fixed order. Only RELATIVE_PATH is
+ // needed, but NAME_STRING has to be stepped over to reach it.
+ if ((flags & HAS_NAME) != 0 && !TryReadStringData(data, ref position, isUnicode, out _))
+ {
+ return false;
+ }
+
+ if ((flags & HAS_RELATIVE_PATH) != 0)
+ {
+ if (!TryReadStringData(data, ref position, isUnicode, out var relativePath))
+ {
+ return false;
+ }
+
+ targets.RelativePath = relativePath;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Turns the parsed fields into absolute paths, most specific first, dropping anything that is
+ /// not on this machine. No file is touched.
+ ///
+ internal static IReadOnlyList BuildCandidatePaths (string shortcutPath, ShortcutTargets targets)
+ {
+ var shortcutDirectory = Path.GetDirectoryName(shortcutPath);
+ var candidates = new List(3);
+
+ AddCandidate(candidates, targets.LocalBasePath);
+ AddCandidate(candidates, JoinPath(targets.LocalBasePath, targets.CommonPathSuffix));
+ AddCandidate(candidates, targets.RelativePath);
+
+ var result = new List(candidates.Count);
+
+ foreach (var candidate in candidates)
+ {
+ var path = candidate;
+
+ if (!Path.IsPathRooted(path))
+ {
+ if (shortcutDirectory is null)
+ {
+ continue;
+ }
+
+ path = Path.Join(shortcutDirectory, path);
+ }
+
+ try
+ {
+ path = Path.GetFullPath(path);
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or NotSupportedException
+ or PathTooLongException
+ or System.Security.SecurityException)
+ {
+ continue;
+ }
+
+ if (IsLocalFilePath(path))
+ {
+ result.Add(path);
+ }
+ else
+ {
+ _logger.Debug("Not following non-local shortcut target {Target}", path);
+ }
+ }
+
+ return result;
+ }
+
+ #endregion
+
+ #region Private methods
+
+ ///
+ /// Whether names an existing file with the shortcut extension. Paths with
+ /// any other extension never touch the disk.
+ ///
+ private static bool IsShortcutFile (string path)
+ {
+ if (!Path.GetExtension(path).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ try
+ {
+ return File.Exists(path);
+ }
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Reads one shortcut and returns its target, without following a target that is itself a
+ /// shortcut.
+ ///
+ /// The first candidate that exists on disk, or null.
+ private static string? TryResolveSingle (string shortcutPath)
+ {
+ var data = TryReadShortcut(shortcutPath);
+ if (data is null)
+ {
+ return null;
+ }
+
+ if (!TryParseTargets(data, out var targets))
+ {
+ _logger.Warn("Ignoring {Shortcut}: not a valid shortcut", shortcutPath);
+ return null;
+ }
+
+ return FindExistingTarget(shortcutPath, targets);
+ }
+
+ private static byte[]? TryReadShortcut (string shortcutPath)
+ {
+ try
+ {
+ using var stream = new FileStream(shortcutPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+
+ if (stream.Length > MAX_SHORTCUT_SIZE)
+ {
+ _logger.Warn("Ignoring {Shortcut}: {Size} bytes is above the {Limit} byte shortcut limit", shortcutPath, stream.Length, MAX_SHORTCUT_SIZE);
+ return null;
+ }
+
+ var bytes = new byte[stream.Length];
+ stream.ReadExactly(bytes);
+ return bytes;
+ }
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException)
+ {
+ _logger.Debug(ex, "Could not read shortcut {Shortcut}", shortcutPath);
+ return null;
+ }
+ }
+
+ private static bool IsShellLink (ReadOnlySpan data)
+ {
+ if (data.Length < SHELL_LINK_HEADER_SIZE)
+ {
+ return false;
+ }
+
+ if (BinaryPrimitives.ReadUInt32LittleEndian(data) != SHELL_LINK_HEADER_SIZE)
+ {
+ return false;
+ }
+
+ return data.Slice(4, ShellLinkClsid.Length).SequenceEqual(ShellLinkClsid);
+ }
+
+ ///
+ /// Steps over LinkTargetIDList, a size-prefixed blob of shell item identifiers.
+ ///
+ private static bool TrySkipIdList (ReadOnlySpan data, ref int position)
+ {
+ if (position + 2 > data.Length)
+ {
+ return false;
+ }
+
+ var idListSize = BinaryPrimitives.ReadUInt16LittleEndian(data[position..]);
+ position += 2 + idListSize;
+ return position <= data.Length;
+ }
+
+ ///
+ /// Reads the local base path and the common path suffix out of LinkInfo. The network part
+ /// is deliberately not read: its targets are never followed.
+ ///
+ private static bool TryReadLinkInfo (ReadOnlySpan data, ref int position, ShortcutTargets targets)
+ {
+ if (position + LINK_INFO_MIN_SIZE > data.Length)
+ {
+ return false;
+ }
+
+ var linkInfoSize = BinaryPrimitives.ReadUInt32LittleEndian(data[position..]);
+ if (linkInfoSize < LINK_INFO_MIN_SIZE || (long)position + linkInfoSize > data.Length)
+ {
+ return false;
+ }
+
+ var linkInfo = data.Slice(position, (int)linkInfoSize);
+ var linkInfoHeaderSize = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[4..]);
+ var linkInfoFlags = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[8..]);
+ var hasUnicodeOffsets = linkInfoHeaderSize >= LINK_INFO_HEADER_SIZE_UNICODE
+ && linkInfoSize >= LINK_INFO_HEADER_SIZE_UNICODE;
+
+ var localBasePathOffset = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[16..]);
+ var commonPathSuffixOffset = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[24..]);
+
+ // The Unicode strings win when present: the ANSI ones are a lossy rendering of them.
+ var localBasePathUnicodeOffset = hasUnicodeOffsets
+ ? BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[28..])
+ : 0;
+
+ var commonPathSuffixUnicodeOffset = hasUnicodeOffsets
+ ? BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[32..])
+ : 0;
+
+ if ((linkInfoFlags & VOLUME_ID_AND_LOCAL_BASE_PATH) != 0)
+ {
+ targets.LocalBasePath = ReadStringAt(linkInfo, localBasePathUnicodeOffset, isUnicode: true)
+ ?? ReadStringAt(linkInfo, localBasePathOffset, isUnicode: false);
+ }
+
+ targets.CommonPathSuffix = ReadStringAt(linkInfo, commonPathSuffixUnicodeOffset, isUnicode: true)
+ ?? ReadStringAt(linkInfo, commonPathSuffixOffset, isUnicode: false);
+
+ position += (int)linkInfoSize;
+ return true;
+ }
+
+ ///
+ /// Reads a NUL-terminated string at inside .
+ ///
+ private static string? ReadStringAt (ReadOnlySpan data, uint offset, bool isUnicode)
+ {
+ if (offset == 0 || offset >= (uint)data.Length)
+ {
+ return null;
+ }
+
+ return TryReadNullTerminatedString(data, (int)offset, isUnicode, out var value) && value.Length > 0
+ ? value
+ : null;
+ }
+
+ private static bool TryReadNullTerminatedString (ReadOnlySpan data, int offset, bool isUnicode, out string value)
+ {
+ value = string.Empty;
+
+ if (offset < 0 || offset >= data.Length)
+ {
+ return false;
+ }
+
+ var slice = data[offset..];
+
+ if (isUnicode)
+ {
+ for (var index = 0; index + 1 < slice.Length; index += 2)
+ {
+ if (slice[index] == 0 && slice[index + 1] == 0)
+ {
+ value = Encoding.Unicode.GetString(slice[..index]);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ var terminator = slice.IndexOf((byte)0);
+ if (terminator < 0)
+ {
+ return false;
+ }
+
+ value = _ansiEncoding.Value.GetString(slice[..terminator]);
+ return true;
+ }
+
+ ///
+ /// Reads one StringData entry: a character count followed by that many characters, with
+ /// no terminator (MS-SHLLINK 2.4).
+ ///
+ private static bool TryReadStringData (ReadOnlySpan data, ref int position, bool isUnicode, out string value)
+ {
+ value = string.Empty;
+
+ if (position + 2 > data.Length)
+ {
+ return false;
+ }
+
+ var characterCount = BinaryPrimitives.ReadUInt16LittleEndian(data[position..]);
+ position += 2;
+
+ var byteCount = isUnicode ? characterCount * 2 : characterCount;
+ if (position + byteCount > data.Length)
+ {
+ return false;
+ }
+
+ value = isUnicode
+ ? Encoding.Unicode.GetString(data.Slice(position, byteCount))
+ : _ansiEncoding.Value.GetString(data.Slice(position, byteCount));
+
+ position += byteCount;
+ return true;
+ }
+
+ ///
+ /// Returns the first candidate path that exists as a file.
+ ///
+ private static string? FindExistingTarget (string shortcutPath, ShortcutTargets targets)
+ {
+ foreach (var candidate in BuildCandidatePaths(shortcutPath, targets))
+ {
+ try
+ {
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException)
+ {
+ _logger.Debug(ex, "Could not look up shortcut target {Target}", candidate);
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Whether stays on this machine. \\host\share would make Windows
+ /// authenticate to that host, handing it the user's NetNTLM hash, and \\.\ or
+ /// \\?\ reach devices and pipes that can block.
+ ///
+ private static bool IsLocalFilePath (string path)
+ {
+ return !path.StartsWith(@"\\", StringComparison.Ordinal);
+ }
+
+ private static void AddCandidate (List candidates, string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ if (!candidates.Contains(path, StringComparer.OrdinalIgnoreCase))
+ {
+ candidates.Add(path);
+ }
+ }
+
+ ///
+ /// Joins a base path and a suffix, tolerating a separator on either side.
+ ///
+ private static string? JoinPath (string? basePath, string? suffix)
+ {
+ if (string.IsNullOrEmpty(basePath) || string.IsNullOrEmpty(suffix))
+ {
+ return null;
+ }
+
+ return Path.Join(
+ basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
+ suffix.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ }
+
+ private static Encoding ResolveAnsiEncoding ()
+ {
+ try
+ {
+ // InstalledUICulture, not CurrentCulture: the UI language the user picked is applied to
+ // CurrentCulture at startup, which would otherwise report the wrong ANSI code page.
+ var codePage = CultureInfo.InstalledUICulture.TextInfo.ANSICodePage;
+ if (codePage > 0)
+ {
+ return EncodingRegistry.GetEncoding(codePage);
+ }
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
+ {
+ // No code pages are available; Latin1 still decodes the ASCII paths this branch sees.
+ }
+
+ return Encoding.Latin1;
+ }
+
+ #endregion
+
+ #region Data types
+
+ ///
+ /// The path fields a shortcut carries, as parsed. A plain holder: resolving them against the
+ /// file system is 's job.
+ ///
+ internal sealed class ShortcutTargets
+ {
+ public string? LocalBasePath { get; set; }
+
+ public string? CommonPathSuffix { get; set; }
+
+ public string? RelativePath { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs b/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs
index 4d84402c8..8d18f1ccc 100644
--- a/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs
+++ b/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs
@@ -1,6 +1,9 @@
+using System.Buffers.Binary;
using System.Globalization;
+using System.Text;
using LogExpert.Core.Classes.Persister;
+using LogExpert.Core.Helpers;
namespace LogExpert.Persister.Tests;
@@ -205,6 +208,42 @@ public void PersisterHelpers_FindFilenameForSettings_MixedLxpAndLog_ResolvesBoth
Assert.That(result[1], Does.EndWith("referenced.log"), ".lxp should resolve to referenced log");
}
+ [Test]
+ public void PersisterHelpers_FindFilenameForSettings_ShortcutToLxp_ResolvesToLog ()
+ {
+ // Arrange - a shortcut to the settings file, which in turn names the log
+ CreateTestLogFiles("actual.log");
+ CreatePersistenceFile("settings.lxp", "actual.log");
+ var shortcutPath = Path.Join(_testDirectory, "settings.lnk");
+ File.WriteAllBytes(shortcutPath, BuildRelativePathShortcut("settings.lxp"));
+
+ // Act - the shortcut is resolved at the entry point, then the settings file is followed
+ var shortcutTarget = ShellLinkResolver.Resolve(shortcutPath);
+ var result = PersisterHelpers.FindFilenameForSettings(shortcutTarget, PluginRegistry.PluginRegistry.Instance);
+
+ // Assert
+ Assert.That(shortcutTarget, Does.EndWith("settings.lxp"), "Shortcut should resolve to the settings file");
+ Assert.That(result, Does.EndWith("actual.log"), "Settings file should then resolve to the log");
+ }
+
+ ///
+ /// Builds a shortcut whose only target is RELATIVE_PATH. The other projects build theirs with a
+ /// fixture; this one needs a handful of bytes, so it writes them here.
+ ///
+ private static byte[] BuildRelativePathShortcut (string relativePath)
+ {
+ var bytes = new byte[0x4C + 2 + (relativePath.Length * 2)];
+
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x4C);
+ new byte[] { 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 }
+ .CopyTo(bytes, 4);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 0x00000088);
+ BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(0x4C), (ushort)relativePath.Length);
+ _ = Encoding.Unicode.GetBytes(relativePath, bytes.AsSpan(0x4E));
+
+ return bytes;
+ }
+
[Test]
public void PersisterHelpers_FindFilenameForSettings_CorruptedLxp_ReturnsLxpPath ()
{
diff --git a/src/LogExpert.Tests/Helpers/ShellLinkFixture.cs b/src/LogExpert.Tests/Helpers/ShellLinkFixture.cs
new file mode 100644
index 000000000..312be3826
--- /dev/null
+++ b/src/LogExpert.Tests/Helpers/ShellLinkFixture.cs
@@ -0,0 +1,151 @@
+using System.Text;
+
+namespace LogExpert.Tests.Helpers;
+
+///
+/// Builds shortcut byte arrays for tests. Only the fields the resolver reads are written: header,
+/// an optional LinkInfo with a local base path and common suffix, and the StringData entries for
+/// NAME_STRING and RELATIVE_PATH.
+///
+internal static class ShellLinkFixture
+{
+ private const int HEADER_SIZE = 0x4C;
+
+ private const uint HAS_LINK_INFO = 0x00000002;
+ private const uint HAS_NAME = 0x00000004;
+ private const uint HAS_RELATIVE_PATH = 0x00000008;
+ private const uint IS_UNICODE = 0x00000080;
+
+ private static ReadOnlySpan Clsid =>
+ [
+ 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46
+ ];
+
+ ///
+ /// Builds a shortcut. selects the Unicode encoding for both LinkInfo
+ /// and StringData, as the IsUnicode flag does in the real format.
+ ///
+ internal static byte[] Build (
+ string? localBasePath = null,
+ string? commonPathSuffix = null,
+ string? name = null,
+ string? relativePath = null,
+ bool unicode = true,
+ bool corruptSignature = false)
+ {
+ var flags = unicode ? IS_UNICODE : 0u;
+
+ if (name is not null)
+ {
+ flags |= HAS_NAME;
+ }
+
+ if (relativePath is not null)
+ {
+ flags |= HAS_RELATIVE_PATH;
+ }
+
+ byte[]? linkInfo = null;
+ if (localBasePath is not null || commonPathSuffix is not null)
+ {
+ flags |= HAS_LINK_INFO;
+ linkInfo = BuildLinkInfo(localBasePath ?? string.Empty, commonPathSuffix ?? string.Empty, unicode);
+ }
+
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter(stream, Encoding.Unicode, true);
+
+ writer.Write((uint)HEADER_SIZE);
+ writer.Write(corruptSignature ? new byte[Clsid.Length] : Clsid.ToArray());
+ writer.Write(flags);
+ writer.Write(new byte[HEADER_SIZE - 24]);
+
+ if (linkInfo is not null)
+ {
+ writer.Write(linkInfo);
+ }
+
+ if (name is not null)
+ {
+ WriteStringData(writer, name, unicode);
+ }
+
+ if (relativePath is not null)
+ {
+ WriteStringData(writer, relativePath, unicode);
+ }
+
+ writer.Flush();
+ return stream.ToArray();
+ }
+
+ private static byte[] BuildLinkInfo (string localBasePath, string commonPathSuffix, bool unicode)
+ {
+ // The ANSI copy is always present when VOLUME_ID_AND_LOCAL_BASE_PATH is set; the Unicode
+ // one only when the header is large enough to carry its offsets.
+ var headerSize = unicode ? 0x24 : 0x1C;
+ var ansiLocal = Encoding.Latin1.GetBytes(localBasePath);
+ var ansiSuffix = Encoding.Latin1.GetBytes(commonPathSuffix);
+
+ var ansiLocalOffset = headerSize;
+ var ansiSuffixOffset = ansiLocalOffset + ansiLocal.Length + 1;
+ var unicodeLocalOffset = ansiSuffixOffset + ansiSuffix.Length + 1;
+ var unicodeSuffixOffset = unicodeLocalOffset + ((localBasePath.Length + 1) * 2);
+
+ var size = unicode
+ ? unicodeSuffixOffset + ((commonPathSuffix.Length + 1) * 2)
+ : ansiSuffixOffset + ansiSuffix.Length + 1;
+
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter(stream, Encoding.Unicode, true);
+
+ writer.Write((uint)size);
+ writer.Write((uint)headerSize);
+ writer.Write(1u); // VOLUME_ID_AND_LOCAL_BASE_PATH
+ writer.Write(0u); // VolumeIDOffset
+ writer.Write((uint)ansiLocalOffset);
+ writer.Write(0u); // CommonNetworkRelativeLinkOffset
+ writer.Write((uint)ansiSuffixOffset);
+
+ if (unicode)
+ {
+ writer.Write((uint)unicodeLocalOffset);
+ writer.Write((uint)unicodeSuffixOffset);
+ }
+
+ writer.Write(ansiLocal);
+ writer.Write((byte)0);
+ writer.Write(ansiSuffix);
+ writer.Write((byte)0);
+
+ if (unicode)
+ {
+ writer.Write(Encoding.Unicode.GetBytes(localBasePath));
+ writer.Write((ushort)0);
+ writer.Write(Encoding.Unicode.GetBytes(commonPathSuffix));
+ writer.Write((ushort)0);
+ }
+
+ writer.Flush();
+ return stream.ToArray();
+ }
+
+ ///
+ /// Writes one StringData entry: a character count, then the characters. MS-SHLLINK 2.4 puts no
+ /// NULL terminator after them.
+ ///
+ private static void WriteStringData (BinaryWriter writer, string value, bool unicode)
+ {
+ writer.Write((ushort)value.Length);
+
+ if (unicode)
+ {
+ writer.Write(Encoding.Unicode.GetBytes(value));
+ }
+ else
+ {
+ writer.Write(Encoding.Latin1.GetBytes(value));
+ }
+ }
+}
diff --git a/src/LogExpert.Tests/Helpers/ShellLinkResolverTests.cs b/src/LogExpert.Tests/Helpers/ShellLinkResolverTests.cs
new file mode 100644
index 000000000..1f5160f6b
--- /dev/null
+++ b/src/LogExpert.Tests/Helpers/ShellLinkResolverTests.cs
@@ -0,0 +1,248 @@
+using LogExpert.Core.Helpers;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Helpers;
+
+///
+/// Tests for . The parsing tests feed crafted byte arrays in, so they
+/// need no real shortcut; the resolution tests use a temporary directory.
+///
+[TestFixture]
+public class ShellLinkResolverTests
+{
+ private string _directory;
+
+ [SetUp]
+ public void Setup ()
+ {
+ _directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShellLink", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(_directory);
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ if (!Directory.Exists(_directory))
+ {
+ return;
+ }
+
+ try
+ {
+ Directory.Delete(_directory, true);
+ }
+ catch (IOException)
+ {
+ // A file still held open by another process; the temp directory gets cleaned up anyway.
+ }
+ }
+
+ #region Helper methods
+
+ private string CreateLog (string relativePath)
+ {
+ var path = Path.Join(_directory, relativePath);
+ _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, "log line");
+ return path;
+ }
+
+ private string CreateShortcut (string fileName, byte[] bytes)
+ {
+ var path = Path.Join(_directory, fileName);
+ File.WriteAllBytes(path, bytes);
+ return path;
+ }
+
+ #endregion
+
+ #region Parsing
+
+ [Test]
+ public void TryParseTargets_UnicodeLinkInfo_ReadsLocalBasePath ()
+ {
+ var data = ShellLinkFixture.Build(localBasePath: @"C:\logs\app.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(targets.LocalBasePath, Is.EqualTo(@"C:\logs\app.log"));
+ }
+
+ [Test]
+ public void TryParseTargets_AnsiLinkInfo_ReadsLocalBasePath ()
+ {
+ var data = ShellLinkFixture.Build(localBasePath: @"C:\logs\app.log", unicode: false);
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(targets.LocalBasePath, Is.EqualTo(@"C:\logs\app.log"));
+ }
+
+ [Test]
+ public void TryParseTargets_NameBeforeRelativePath_ReadsRelativePath ()
+ {
+ // StringData carries no NULL terminator (MS-SHLLINK 2.4). Skipping one used to land four
+ // bytes into RELATIVE_PATH, which the old resolution order then hid.
+ var data = ShellLinkFixture.Build(name: "a comment", relativePath: @"sub\target.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(targets.RelativePath, Is.EqualTo(@"sub\target.log"));
+ }
+
+ [Test]
+ public void TryParseTargets_TruncatedHeader_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: "app.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data.AsSpan(0, 0x40), out _), Is.False);
+ }
+
+ [Test]
+ public void TryParseTargets_TruncatedLinkInfo_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(localBasePath: @"C:\logs\app.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data.AsSpan(0, 0x4C + 10), out _), Is.False);
+ }
+
+ [Test]
+ public void TryParseTargets_TruncatedStringData_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: "a-long-relative-path.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data.AsSpan(0, data.Length - 4), out _), Is.False);
+ }
+
+ [Test]
+ public void TryParseTargets_CorruptSignature_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: "app.log", corruptSignature: true);
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out _), Is.False);
+ }
+
+ #endregion
+
+ #region Candidate paths
+
+ [Test]
+ public void BuildCandidatePaths_LocalTarget_ReturnsAbsolutePath ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: Path.Join("sub", "app.log"));
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(ShellLinkResolver.BuildCandidatePaths(Path.Join(_directory, "link.lnk"), targets),
+ Is.EqualTo(new[] { Path.Join(_directory, "sub", "app.log") }));
+ }
+
+ [TestCase(@"\\server\share\app.log")]
+ [TestCase(@"\\.\pipe\app.log")]
+ [TestCase(@"\\?\C:\logs\app.log")]
+ public void BuildCandidatePaths_NonLocalTarget_IsSkipped (string target)
+ {
+ // Following these would contact another host (leaking the NetNTLM hash) or block on a
+ // device. They must not even become candidates.
+ var data = ShellLinkFixture.Build(localBasePath: target);
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(ShellLinkResolver.BuildCandidatePaths(Path.Join(_directory, "link.lnk"), targets), Is.Empty);
+ }
+
+ #endregion
+
+ #region Resolution
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void Resolve_RelativePathTarget_ReturnsTarget (bool unicode)
+ {
+ var target = CreateLog(Path.Join("sub", "app.log"));
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(relativePath: Path.Join("sub", "app.log"), unicode: unicode));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_LocalBasePath_ReturnsTarget ()
+ {
+ var target = CreateLog("app.log");
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(localBasePath: target));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_BasePathWithCommonSuffix_ReturnsTarget ()
+ {
+ var target = CreateLog("app.log");
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(
+ localBasePath: _directory,
+ commonPathSuffix: "app.log"));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_NonShortcutPath_ReturnsUnchanged ()
+ {
+ var missing = Path.Join(_directory, "missing.log");
+
+ Assert.That(ShellLinkResolver.Resolve(missing), Is.EqualTo(missing));
+ }
+
+ [Test]
+ public void Resolve_MissingTarget_ReturnsShortcutPath ()
+ {
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(relativePath: "gone.log"));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ }
+
+ [Test]
+ public void Resolve_ChainOfShortcuts_ReturnsFinalTarget ()
+ {
+ var target = CreateLog("app.log");
+ _ = CreateShortcut("second.lnk", ShellLinkFixture.Build(relativePath: "app.log"));
+ var first = CreateShortcut("first.lnk", ShellLinkFixture.Build(relativePath: "second.lnk"));
+
+ Assert.That(ShellLinkResolver.Resolve(first), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_BrokenSecondLink_ReturnsOriginalInput ()
+ {
+ // Not the intermediate shortcut: a half-followed chain is no more usable than the input.
+ _ = CreateShortcut("second.lnk", ShellLinkFixture.Build(relativePath: "gone.log"));
+ var first = CreateShortcut("first.lnk", ShellLinkFixture.Build(relativePath: "second.lnk"));
+
+ Assert.That(ShellLinkResolver.Resolve(first), Is.EqualTo(first));
+ }
+
+ [Test]
+ public void Resolve_Loop_ReturnsOriginalInput ()
+ {
+ var first = CreateShortcut("first.lnk", ShellLinkFixture.Build(relativePath: "second.lnk"));
+ _ = CreateShortcut("second.lnk", ShellLinkFixture.Build(relativePath: "first.lnk"));
+
+ Assert.That(ShellLinkResolver.Resolve(first), Is.EqualTo(first));
+ }
+
+ [Test]
+ public void Resolve_CorruptShortcut_ReturnsUnchanged ()
+ {
+ var shortcut = CreateShortcut("corrupt.lnk", ShellLinkFixture.Build(relativePath: "app.log", corruptSignature: true));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ }
+
+ [Test]
+ public void Resolve_OversizedFile_ReturnsUnchanged ()
+ {
+ var bytes = new byte[(1024 * 1024) + 1];
+ ShellLinkFixture.Build(relativePath: "app.log").CopyTo(bytes, 0);
+ var shortcut = CreateShortcut("huge.lnk", bytes);
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ }
+
+ #endregion
+}
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 2625fb94b..dec57ce71 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -1,3 +1,4 @@
+using System.Buffers.Binary;
using System.Runtime.Versioning;
using System.Text;
@@ -657,6 +658,46 @@ public void LoadFilesWithOption_SingleFile_CallsAddFileTab ()
Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("test.log"));
}
+ [Test]
+ public void LoadFilesWithOption_ShortcutToLxjFile_CallsProjectCallback ()
+ {
+ // 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
+ {
+ var decision = _sut.LoadFilesWithOption(new[] { shortcut }, false);
+
+ 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 ()
+ {
+ var (directory, shortcut, session) = CreateShortcutToSession();
+
+ try
+ {
+ _sut.AddFileTabs(new[] { shortcut });
+
+ 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 LoadFilesWithOption_SingleLxjFile_CallsProjectCallback ()
{
@@ -1225,6 +1266,42 @@ 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 = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShortcutSession", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+
+ var session = Path.Join(directory, "project.lxj");
+ File.WriteAllText(session, "");
+
+ var shortcut = Path.Join(directory, "session.lnk");
+ WriteShortcut(shortcut, "project.lxj");
+
+ return (directory, shortcut, session);
+ }
+
+ ///
+ /// Writes a minimal shortcut: the header, IS_UNICODE | HAS_RELATIVE_PATH, then one StringData
+ /// entry. MS-SHLLINK 2.4 puts no NULL terminator after it.
+ ///
+ private static void WriteShortcut (string shortcutPath, string relativePath)
+ {
+ var bytes = new byte[0x4C + 2 + (relativePath.Length * 2)];
+
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x4C);
+ new byte[] { 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 }
+ .CopyTo(bytes, 4);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 0x00000088);
+ BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(0x4C), (ushort)relativePath.Length);
+ _ = Encoding.Unicode.GetBytes(relativePath, bytes.AsSpan(0x4E));
+
+ File.WriteAllBytes(shortcutPath, bytes);
+ }
+
///
/// 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/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
index 5ec992ae2..fc3aaf141 100644
--- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
+++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
@@ -170,9 +170,11 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu
[SupportedOSPlatform("windows")]
private LogWindow.LogWindow CreateLogWindowFromRequest (FileTabRequest request, EncodingOptions encodingOptions)
{
+ // Resolved by FileOperationService at the entry point; FileName keeps the path the user
+ // picked, which the window reports back as GivenFileName.
LogWindow.LogWindow logWindow = new(
_logWindowCoordinator,
- PersisterHelpers.FindFilenameForSettings(request.FileName, PluginRegistry.PluginRegistry.Instance),
+ request.ResolvedFileName ?? request.FileName,
request.IsTempFile,
request.ForcePersistenceLoading,
ConfigManager,
@@ -205,7 +207,11 @@ private void OnFileOperationServiceFileOpened (object? sender, FileOpenedEventAr
if (!e.Request.IsTempFile)
{
- SetTooltipText(e.LogWindow, e.ResolvedFileName);
+ // Show both sides when they differ: a shortcut or a settings file would otherwise hide
+ // which file the tab is really showing.
+ SetTooltipText(e.LogWindow, string.Equals(e.Request.FileName, e.ResolvedFileName, StringComparison.Ordinal)
+ ? e.ResolvedFileName
+ : $"{e.Request.FileName} → {e.ResolvedFileName}");
}
// Filter tooltip setup
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 0c72551c6..e798a8fb6 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -68,7 +68,10 @@ internal sealed class FileOperationService (
public LogWindow AddFileTab (FileTabRequest request)
{
- var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry);
+ // The one place a shortcut is resolved: everything downstream opens the target, while the
+ // request keeps the path the user picked for the title, history and recent files.
+ var requestedFile = ShellLinkResolver.Resolve(request.FileName);
+ var logFileName = PersisterHelpers.FindFilenameForSettings(requestedFile, _pluginRegistry);
var normalizedPath = Path.GetFullPath(logFileName);
var existingWindow = FindWindowForFile(normalizedPath);
@@ -95,16 +98,17 @@ public LogWindow AddFileTab (FileTabRequest request)
encodingOptions.Encoding = new UnicodeEncoding(false, false);
}
- var logWindow = _logWindowFactory(request, encodingOptions);
+ var tabRequest = request with { ResolvedFileName = logFileName };
+ var logWindow = _logWindowFactory(tabRequest, encodingOptions);
if (!request.IsTempFile)
{
AddToFileHistory(request.FileName);
}
- if (request.FileName.EndsWith(".lxp", StringComparison.Ordinal))
+ if (requestedFile.EndsWith(".lxp", StringComparison.Ordinal))
{
- logWindow.ForcedPersistenceFileName = request.FileName;
+ logWindow.ForcedPersistenceFileName = requestedFile;
}
// Register before starting the asynchronous load, including very small files.
@@ -204,14 +208,18 @@ public LogWindow AddTempFileTab (string fileName, string title)
return null;
}
+ // Resolved here as well: only the reader needs the targets, the request keeps the dropped
+ // paths for history and the title.
+ var resolvedFileNames = fileNames.Select(ShellLinkResolver.Resolve).ToArray();
+
// 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
// a raw log line in the multi-file tab.
- if (fileNames.Any(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)))
+ if (resolvedFileNames.Any(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)))
{
- AddFileTabs(fileNames.Where(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray());
+ AddFileTabs(resolvedFileNames.Where(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray());
- var logs = fileNames.Where(f => !f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray();
+ var logs = resolvedFileNames.Where(f => !f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray();
return logs.Length > 0 ? AddMultiFileTab(logs) : null;
}
@@ -221,7 +229,8 @@ public LogWindow AddTempFileTab (string fileName, string title)
var request = new FileTabRequest
{
FileName = fileNames[^1],
- IsTempFile = false
+ IsTempFile = false,
+ ResolvedFileName = PersisterHelpers.FindFilenameForSettings(resolvedFileNames[^1], _pluginRegistry)
};
var logWindow = _logWindowFactory(request, encodingOptions);
@@ -235,9 +244,9 @@ public LogWindow AddTempFileTab (string fileName, string title)
{
LogWindow = logWindow,
Request = request,
- ResolvedFileName = fileNames[^1],
+ ResolvedFileName = resolvedFileNames[^1],
EncodingOptions = encodingOptions,
- MultiFileNames = fileNames
+ MultiFileNames = resolvedFileNames
});
return logWindow;
@@ -270,9 +279,12 @@ public MultiFileDecision LoadFilesWithOption (string[] fileNames, bool invertLog
if (fileNames.Length == 1)
{
- if (fileNames[0].EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
+ // Resolve first: a shortcut to a session file must not be opened as a raw log.
+ var resolved = ShellLinkResolver.Resolve(fileNames[0]);
+
+ if (resolved.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
{
- _loadSessionCallback(fileNames[0], true);
+ _loadSessionCallback(resolved, true);
return MultiFileDecision.Cancel; // Already handled
}
@@ -310,9 +322,12 @@ public void AddFileTabs (string[] fileNames, int? targetLine = null)
{
try
{
- if (fileName.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
+ // Resolve first: a shortcut to a session file must not be opened as a raw log.
+ var resolved = ShellLinkResolver.Resolve(fileName);
+
+ if (resolved.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
{
- _loadSessionCallback(fileName, false);
+ _loadSessionCallback(resolved, false);
}
else
{
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs b/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs
index 0e4180d56..62ef6bbfb 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs
@@ -8,10 +8,17 @@ namespace LogExpert.UI.Services.FileOperationService;
internal sealed record FileTabRequest
{
///
- /// The file name as provided by the caller (may be relative, may be a .lxp settings file).
+ /// The file name as provided by the caller (may be relative, may be a .lxp settings file or a
+ /// shortcut). This is what gets shown and remembered.
///
public required string FileName { get; init; }
+ ///
+ /// The file the reader actually opens, after shortcuts and .lxp indirection. Filled in by
+ /// FileOperationService at the entry point; null means "same as ".
+ ///
+ public string? ResolvedFileName { get; init; }
+
/// Optional one-based line to select after loading and Session File restoration.
public int? TargetLine { get; init; }