diff --git a/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs b/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs new file mode 100644 index 000000000..b4e4a1e44 --- /dev/null +++ b/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs @@ -0,0 +1,282 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Composition; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; +using ICSharpCode.Decompiler.Util; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.Docking; +using ILSpy.TextView; +using ILSpy.TreeNodes; + +using static ICSharpCode.ILSpyX.LoadedPackage; + +namespace ILSpy.Commands +{ + /// + /// Right-click → "Extract Package Entry". Saves a selection of bundle items + /// (assemblies, resource entries, package folders) to disk. Single-item selection + /// pops a file-save dialog; multi-item or folder selection pops a folder-pick. + /// + [ExportContextMenuEntry(Header = nameof(Resources.ExtractPackageEntry), Category = nameof(Resources.Save), Icon = "Images/Save")] + [Shared] + public sealed class ExtractPackageEntryContextMenuEntry : IContextMenuEntry + { + readonly DockWorkspace dockWorkspace; + + [ImportingConstructor] + public ExtractPackageEntryContextMenuEntry(DockWorkspace dockWorkspace) + { + this.dockWorkspace = dockWorkspace; + } + + public bool IsEnabled(TextViewContext context) => true; + + public bool IsVisible(TextViewContext context) + => context.SelectedTreeNodes?.Any(IsBundleItem) == true; + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes == null) + return; + var selectedNodes = Array.FindAll(context.SelectedTreeNodes, IsBundleItem); + if (selectedNodes.Length == 0) + return; + // Initial directory for the picker — the path of the outer bundle assembly. + var bundleNode = selectedNodes.FirstOrDefault()?.Ancestors().OfType() + .FirstOrDefault(asm => asm.PackageEntry == null); + if (bundleNode == null) + return; + _ = ExecuteAsync(selectedNodes); + } + + async Task ExecuteAsync(SharpTreeNode[] selectedNodes) + { + if (selectedNodes is [AssemblyTreeNode { PackageEntry: { } assembly }]) + { + var defaultName = Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(assembly.Name)); + var path = await FilePickers.SaveAsync( + ".NET assemblies (*.dll, *.exe, *.winmd)|*.dll;*.exe;*.winmd|All files|*.*", + defaultName); + if (string.IsNullOrEmpty(path)) + return; + await SaveAsync(selectedNodes, path, isFile: true); + } + else if (selectedNodes is [ResourceTreeNode { Resource: { } resource }]) + { + var defaultName = Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(resource.Name)); + var path = await FilePickers.SaveAsync("All files|*.*", defaultName); + if (string.IsNullOrEmpty(path)) + return; + await SaveAsync(selectedNodes, path, isFile: true); + } + else + { + var folder = await FilePickers.PickFolderAsync("Select target folder"); + if (string.IsNullOrEmpty(folder)) + return; + await SaveAsync(selectedNodes, folder, isFile: false); + } + } + + internal Task SaveAsync(IReadOnlyList nodes, string path, bool isFile) + => SaveAsync(dockWorkspace, nodes, path, isFile); + + internal static async Task SaveAsync(DockWorkspace dockWorkspace, IReadOnlyList nodes, string path, bool isFile) + { + try + { + var output = await dockWorkspace.RunWithCancellation(token => Task.Run(() => { + var output = new AvaloniaEditTextOutput { Title = "Extract package entry" }; + var stopwatch = Stopwatch.StartNew(); + var fileNameCounts = new Dictionary(Platform.FileNameComparer); + foreach (var (entry, fileName) in CollectAllFiles(nodes)) + { + var actualFileName = WholeProjectDecompiler.SanitizeFileName(fileName); + while (fileNameCounts.TryGetValue(actualFileName, out var index)) + { + index++; + fileNameCounts[actualFileName] = index; + actualFileName = Path.ChangeExtension(actualFileName, index + Path.GetExtension(actualFileName)); + } + if (!fileNameCounts.ContainsKey(actualFileName)) + fileNameCounts[actualFileName] = 1; + SaveEntry(output, entry, isFile ? path : Path.Combine(path, actualFileName)); + } + stopwatch.Stop(); + output.Write(string.Format(Resources.GenerationCompleteInSeconds, stopwatch.Elapsed.TotalSeconds.ToString("F1"))); + output.WriteLine(); + output.WriteLine(); + var openTarget = isFile ? path : path; + output.AddButton(null, Resources.OpenExplorer, (_, _) => OpenInShell(openTarget, isFile)); + output.WriteLine(); + return output; + }, token), "Extracting…"); + dockWorkspace.ShowText(output); + } + catch (OperationCanceledException) + { + // User cancelled — leave the previous tab content visible. + } + } + + static IEnumerable<(PackageEntry Entry, string TargetFileName)> CollectAllFiles(IReadOnlyList nodes) + { + foreach (var node in nodes) + { + if (node is AssemblyTreeNode { PackageEntry: { } assembly }) + { + yield return (assembly, Path.GetFileName(assembly.FullName)); + } + else if (node is ResourceTreeNode { Resource: PackageEntry { } resource }) + { + yield return (resource, Path.GetFileName(resource.FullName)); + } + else if (node is AssemblyTreeNode { PackageKind: not null } asm) + { + var package = asm.LoadedAssembly.GetLoadResultAsync().GetAwaiter().GetResult().Package!; + foreach (var entry in package.Entries) + yield return (entry, entry.FullName); + } + else if (node is PackageFolderTreeNode folder) + { + int prefixLength = 0; + var current = folder.Folder; + if (nodes.Count > 1) + current = current.Parent; + while (current != null) + { + prefixLength += current.Name.Length + 1; + current = current.Parent; + } + if (prefixLength > 0) + prefixLength--; + foreach (var item in TreeTraversal.PreOrder(folder.Folder, f => f.Folders).SelectMany(f => f.Entries)) + { + yield return (item, item.FullName.Substring(prefixLength)); + } + } + } + } + + static void SaveEntry(ITextOutput output, PackageEntry entry, string targetFileName) + { + output.Write(entry.Name + ": "); + using var stream = entry.TryOpenStream(); + if (stream == null) + { + output.WriteLine("Could not open stream!"); + return; + } + Directory.CreateDirectory(Path.GetDirectoryName(targetFileName)!); + stream.Position = 0; + using var fileStream = new FileStream(targetFileName, FileMode.OpenOrCreate); + stream.CopyTo(fileStream); + output.Write("Written to "); + output.Write(targetFileName); + output.WriteLine(); + } + + static void OpenInShell(string path, bool selectItem) + { + try + { + if (OperatingSystem.IsWindows()) + { + var args = selectItem ? $"/select,\"{path}\"" : $"\"{path}\""; + Process.Start(new ProcessStartInfo("explorer.exe", args) { UseShellExecute = false }); + } + else if (OperatingSystem.IsMacOS()) + { + var args = selectItem ? $"-R \"{path}\"" : $"\"{path}\""; + Process.Start(new ProcessStartInfo("open", args) { UseShellExecute = false }); + } + else + { + var target = selectItem ? Path.GetDirectoryName(path)! : path; + Process.Start(new ProcessStartInfo("xdg-open", target) { UseShellExecute = false }); + } + } + catch + { + // Best-effort: user can navigate manually if the shell call fails. + } + } + + static bool IsBundleItem(SharpTreeNode node) + { + if (node is AssemblyTreeNode { PackageEntry: { } } or PackageFolderTreeNode) + return true; + if (node is ResourceTreeNode { Resource: PackageEntry { } resource } && resource.PackageQualifiedFileName.StartsWith("bundle://")) + return true; + return false; + } + } + + /// + /// Right-click an unpacked bundle assembly → "Extract All Package Entries". Saves + /// every entry inside the bundle into the picked folder. Shortcut for the multi- + /// select case in . + /// + [ExportContextMenuEntry(Header = nameof(Resources.ExtractAllPackageEntries), Category = nameof(Resources.Save), Icon = "Images/Save")] + [Shared] + public sealed class ExtractAllPackageEntriesContextMenuEntry : IContextMenuEntry + { + readonly DockWorkspace dockWorkspace; + + [ImportingConstructor] + public ExtractAllPackageEntriesContextMenuEntry(DockWorkspace dockWorkspace) + { + this.dockWorkspace = dockWorkspace; + } + + public bool IsEnabled(TextViewContext context) => true; + + public bool IsVisible(TextViewContext context) + { + return context.SelectedTreeNodes is [AssemblyTreeNode { PackageEntry: null, PackageKind: PackageKind.Bundle }]; + } + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes is not [AssemblyTreeNode { PackageEntry: null } asm]) + return; + _ = ExecuteAsync(asm); + } + + async Task ExecuteAsync(AssemblyTreeNode asm) + { + var folder = await FilePickers.PickFolderAsync("Select target folder"); + if (string.IsNullOrEmpty(folder)) + return; + // Reuses the shared Save pipeline from ExtractPackageEntryContextMenuEntry. + await ExtractPackageEntryContextMenuEntry.SaveAsync(dockWorkspace, new[] { (SharpTreeNode)asm }, folder, isFile: false); + } + } +} diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index 7af70e72c..618c567b5 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -53,6 +53,21 @@ namespace ILSpy.TreeNodes /// public PackageEntry? PackageEntry { get; } + /// + /// If this assembly is itself a bundle/package (nuget .zip or .NET single-file + /// bundle), returns the package kind. Returns for plain + /// stand-alone assemblies and for any assembly that hasn't finished loading yet + /// or failed to load. + /// + public LoadedPackage.PackageKind? PackageKind { + get { + if (LoadedAssembly.HasLoadError || !LoadedAssembly.IsLoaded) + return null; + var loadResult = LoadedAssembly.GetLoadResultAsync().GetAwaiter().GetResult(); + return loadResult.Package?.Kind; + } + } + public AssemblyTreeNode(LoadedAssembly assembly) : this(assembly, null) { }