diff --git a/Extensions/dnSpy.AsmEditor/Bundle/BundleNameCleaner.cs b/Extensions/dnSpy.AsmEditor/Bundle/BundleNameCleaner.cs new file mode 100644 index 0000000000..d14a97e4e5 --- /dev/null +++ b/Extensions/dnSpy.AsmEditor/Bundle/BundleNameCleaner.cs @@ -0,0 +1,49 @@ +using System.Text; +using System; +using System.IO; +using System.Collections.Generic; + +namespace dnSpy.AsmEditor.Bundle { + /// + /// Cleans bundle entry name + /// + public static class BundleNameCleaner { + static readonly HashSet invalidFileNameChar = new HashSet(); + static BundleNameCleaner() { + foreach (var c in Path.GetInvalidFileNameChars()) + invalidFileNameChar.Add(c); + foreach (var c in Path.GetInvalidPathChars()) + invalidFileNameChar.Add(c); + } + + public static string GetCleanedPath(string s, bool useSubDirs) { + if (!useSubDirs) + return FixFileNamePart(GetFileName(s)); + + string res = string.Empty; + foreach (var part in s.Replace('/', '\\').Split('\\')) + res = Path.Combine(res, FixFileNamePart(part)); + return res; + } + + public static string GetFileName(string s) { + int index = Math.Max(s.LastIndexOf('/'), s.LastIndexOf('\\')); + if (index < 0) + return s; + return s.Substring(index + 1); + } + + public static string FixFileNamePart(string s) { + var sb = new StringBuilder(s.Length); + + foreach (var c in s) { + if (invalidFileNameChar.Contains(c)) + sb.Append('_'); + else + sb.Append(c); + } + + return sb.ToString(); + } + } +} diff --git a/Extensions/dnSpy.AsmEditor/Bundle/SaveBundle.cs b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundle.cs new file mode 100644 index 0000000000..4e3574d328 --- /dev/null +++ b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundle.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Threading; +using dnSpy.AsmEditor.Properties; +using dnSpy.Contracts.App; +using dnSpy.Contracts.Bundles; +using dnSpy.Contracts.MVVM; +using dnSpy.Contracts.MVVM.Dialogs; +using Ookii.Dialogs.Wpf; +using WF = System.Windows.Forms; + +namespace dnSpy.AsmEditor.Bundle { + /// + /// For saving bundle entries + /// + public static class SaveBundle { + + /// + /// Gets the save path of each files in bundle + /// + /// + /// + /// + static IEnumerable<(BundleEntry data, string filename)> GetFiles(BundleEntry[] infos, bool useSubDirs) { + if (infos.Length == 1) { + var info = infos[0]; + var name = BundleNameCleaner.FixFileNamePart(BundleNameCleaner.GetFileName(info.FileName)); + var dlg = new WF.SaveFileDialog { + Filter = PickFilenameConstants.AnyFilenameFilter, + RestoreDirectory = true, + ValidateNames = true, + FileName = name, + }; + var ext = Path.GetExtension(name); + dlg.DefaultExt = string.IsNullOrEmpty(ext) ? string.Empty : ext.Substring(1); + if (dlg.ShowDialog() != WF.DialogResult.OK) + yield break; + yield return (info, dlg.FileName); + } + else { + var dlg = new VistaFolderBrowserDialog(); + if (dlg.ShowDialog() != true) + yield break; + string baseDir = dlg.SelectedPath; + foreach (var info in infos) { + var name = BundleNameCleaner.GetCleanedPath(info.FileName, useSubDirs); + var pathName = Path.Combine(baseDir, name); + yield return (info, pathName); + } + } + } + + /// + /// Saves the bundle entry nodes + /// + /// Nodes + /// true to create sub directories, false to dump everything in the same folder + public static void Save(BundleEntry[] entries, string title) { + if (entries is null) + return; + + (BundleEntry bundleEntry, string filename)[] bundleSaveInfo; + try { + bundleSaveInfo = GetFiles(entries, true).ToArray(); + } + catch (Exception ex) { + MsgBox.Instance.Show(ex); + return; + } + if (bundleSaveInfo.Length == 0) + return; + + var data = new ProgressVM(Dispatcher.CurrentDispatcher, new BundleSaver(bundleSaveInfo)); + var win = new ProgressDlg(); + win.DataContext = data; + win.Owner = Application.Current.MainWindow; + win.Title = title; + var res = win.ShowDialog(); + if (res != true) + return; + if (!data.WasError) + return; + MsgBox.Instance.Show(string.Format(dnSpy_AsmEditor_Resources.SaveBundleError, data.ErrorMessage)); + } + + sealed class BundleSaver : IProgressTask { + public bool IsIndeterminate => false; + public double ProgressMinimum => 0; + public double ProgressMaximum => bundleSaveInfo.Length; + + readonly (BundleEntry bundleEntry, string filename)[] bundleSaveInfo; + + public BundleSaver((BundleEntry bundleEntry, string filename)[] bundleSaveInfo) => this.bundleSaveInfo = bundleSaveInfo; + + public void Execute(IProgress progress) { + for (int i = 0; i < bundleSaveInfo.Length; i++) { + progress.ThrowIfCancellationRequested(); + var saveInfo = bundleSaveInfo[i]; + progress.SetDescription(saveInfo.filename); + progress.SetTotalProgress(i); + Directory.CreateDirectory(Path.GetDirectoryName(saveInfo.filename)!); + try { + byte[]? data = saveInfo.bundleEntry.GetEntryData(); + Debug2.Assert(data != null); + File.WriteAllBytes(saveInfo.filename, data); + } + catch { + try { File.Delete(saveInfo.filename); } + catch { } + throw; + } + } + progress.SetTotalProgress(bundleSaveInfo.Length); + } + } + } +} diff --git a/Extensions/dnSpy.AsmEditor/Bundle/SaveBundleContentsCommands.cs b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundleContentsCommands.cs new file mode 100644 index 0000000000..d11f5119f9 --- /dev/null +++ b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundleContentsCommands.cs @@ -0,0 +1,85 @@ +using System.ComponentModel.Composition; +using System.Diagnostics; +using System.Linq; +using dnSpy.AsmEditor.Commands; +using dnSpy.AsmEditor.Properties; +using dnSpy.Contracts.Documents; +using dnSpy.Contracts.Documents.TreeView; +using dnSpy.Contracts.Menus; +using dnSpy.Contracts.TreeView; + +namespace dnSpy.AsmEditor.Bundle { + sealed class SaveBundleContentsCommand { + [ExportMenuItem(Header = "res:SaveBundleContents", Group = MenuConstants.GROUP_CTX_DOCUMENTS_ASMED_BUNDLE, Order = 0)] + sealed class DocumentsCommand : DocumentsContextMenuHandler { + public override bool IsVisible(AsmEditorContext context) => SaveBundleContentsCommand.CanExecute(context); + public override void Execute(AsmEditorContext context) => SaveBundleContentsCommand.Execute(context); + } + + [ExportMenuItem(OwnerGuid = MenuConstants.APP_MENU_EDIT_GUID, Header = "res:SaveBundleContents", Group = MenuConstants.GROUP_APP_MENU_EDIT_ASMED_BUNDLE, Order = 0)] + sealed class EditMenuCommand : EditMenuHandler { + [ImportingConstructor] + public EditMenuCommand(IAppService appService) : base(appService.DocumentTreeView) { + } + public override bool IsVisible(AsmEditorContext context) => SaveBundleContentsCommand.CanExecute(context); + public override void Execute(AsmEditorContext context) => SaveBundleContentsCommand.Execute(context); + } + + static bool IsSingleFileBundle(AsmEditorContext context) => context.Nodes.Length == 1 && context.Nodes[0] is BundleDocumentNode; + static bool CanExecute(AsmEditorContext context) => SaveBundleContentsCommand.IsSingleFileBundle(context); + static void Execute(AsmEditorContext context) { + var docNode = context.Nodes[0].GetDocumentNode(); + var bundleDoc = docNode!.Document as DsBundleDocument; + Debug2.Assert(bundleDoc != null); + Debug2.Assert(bundleDoc.SingleFileBundle != null); + SaveBundle.Save(bundleDoc.SingleFileBundle.Entries.ToArray(), dnSpy_AsmEditor_Resources.SaveBundleContents); + } + } + + sealed class SaveRawEntryCommand { + [ExportMenuItem(Header = "res:SaveRawEntry", Group = MenuConstants.GROUP_CTX_DOCUMENTS_ASMED_BUNDLE, Order = 1)] + sealed class DocumentsCommand : DocumentsContextMenuHandler { + public override bool IsVisible(AsmEditorContext context) => SaveRawEntryCommand.CanExecute(context); + public override void Execute(AsmEditorContext context) => SaveRawEntryCommand.Execute(context); + } + + [ExportMenuItem(OwnerGuid = MenuConstants.APP_MENU_EDIT_GUID, Header = "res:SaveRawEntry", Group = MenuConstants.GROUP_APP_MENU_EDIT_ASMED_BUNDLE, Order = 1)] + sealed class EditMenuCommand : EditMenuHandler { + [ImportingConstructor] + public EditMenuCommand(IAppService appService) : base(appService.DocumentTreeView) { + } + public override bool IsVisible(AsmEditorContext context) => SaveRawEntryCommand.CanExecute(context); + public override void Execute(AsmEditorContext context) => SaveRawEntryCommand.Execute(context); + } + + static bool IsBundleSingleSelection(AsmEditorContext context) => context.Nodes.Length == 1 && context.Nodes[0] is IBundleEntryNode; + static bool CanExecute(AsmEditorContext context) => SaveRawEntryCommand.IsBundleSingleSelection(context); + static void Execute(AsmEditorContext context) { + var bundleEntryNode = (IBundleEntryNode)context.Nodes[0]; + SaveBundle.Save([bundleEntryNode.BundleEntry!], dnSpy_AsmEditor_Resources.SaveRawEntry); + } + } + + class SaveRawEntriesCommand { + [ExportMenuItem(Header = "res:SaveRawEntries", Group = MenuConstants.GROUP_CTX_DOCUMENTS_ASMED_BUNDLE, Order = 2)] + sealed class DocumentsCommand : DocumentsContextMenuHandler { + public override bool IsVisible(AsmEditorContext context) => SaveRawEntriesCommand.CanExecute(context); + public override void Execute(AsmEditorContext context) => SaveRawEntriesCommand.Execute(context); + } + + [ExportMenuItem(OwnerGuid = MenuConstants.APP_MENU_EDIT_GUID, Header = "res:SaveRawEntries", Group = MenuConstants.GROUP_APP_MENU_EDIT_ASMED_BUNDLE, Order = 2)] + sealed class EditMenuCommand : EditMenuHandler { + [ImportingConstructor] + public EditMenuCommand(IAppService appService) : base(appService.DocumentTreeView) { + } + public override bool IsVisible(AsmEditorContext context) => SaveRawEntriesCommand.CanExecute(context); + public override void Execute(AsmEditorContext context) => SaveRawEntriesCommand.Execute(context); + } + private static bool IsBundleMultipleSelection(AsmEditorContext context) => context.Nodes.Length > 1 && context.Nodes.All(node => node is IBundleEntryNode); + static bool CanExecute(AsmEditorContext context) => SaveRawEntriesCommand.IsBundleMultipleSelection(context); + static void Execute(AsmEditorContext context) { + var bundleEntries = context.Nodes.Select(x => ((IBundleEntryNode)x).BundleEntry!); + SaveBundle.Save(bundleEntries.ToArray(), dnSpy_AsmEditor_Resources.SaveRawEntries); + } + } +} diff --git a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs index d56beba536..bc0e83b0ad 100644 --- a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs +++ b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -18,7 +19,7 @@ namespace dnSpy.AsmEditor.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class dnSpy_AsmEditor_Resources { @@ -5109,6 +5110,24 @@ public static string SaveAllToolBarToolTip { } } + /// + /// Looks up a localized string similar to Save Bundle Contents. + /// + public static string SaveBundleContents { + get { + return ResourceManager.GetString("SaveBundleContents", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An error occured while saving bundle: {0}. + /// + public static string SaveBundleError { + get { + return ResourceManager.GetString("SaveBundleError", resourceCulture); + } + } + /// /// Looks up a localized string similar to _Add MVID Section. /// @@ -5646,6 +5665,24 @@ public static string SaveModulesCommand { } } + /// + /// Looks up a localized string similar to Save Raw Entries. + /// + public static string SaveRawEntries { + get { + return ResourceManager.GetString("SaveRawEntries", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Save Raw Entry. + /// + public static string SaveRawEntry { + get { + return ResourceManager.GetString("SaveRawEntry", resourceCulture); + } + } + /// /// Looks up a localized string similar to Save {0}. /// diff --git a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx index 9a079e3716..f63823912a 100644 --- a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx +++ b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx @@ -1,17 +1,17 @@  - @@ -2327,4 +2327,16 @@ Most options will be re-initialized when this checkbox is clicked Visibilit_y - + + Save Bundle Contents + + + An error occured while saving bundle: {0} + + + Save Raw Entries + + + Save Raw Entry + + \ No newline at end of file diff --git a/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs b/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs index 52b4c250ae..75af4db3a8 100644 --- a/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs +++ b/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs @@ -211,23 +211,26 @@ public static class MenuConstants { /// Group: App Menu: Edit, Group: AsmEditor Misc public const string GROUP_APP_MENU_EDIT_ASMED_MISC = "3000,3DCA360E-3CCD-4F27-AF50-A254CD5F9C83"; + /// Group: App Menu: Edit, Group: AsmEditor Bundle + public const string GROUP_APP_MENU_EDIT_ASMED_BUNDLE = "4000,160236C6-6827-4C0C-9902-9B80EBD62B7E"; + /// Group: App Menu: Edit, Group: AsmEditor New - public const string GROUP_APP_MENU_EDIT_ASMED_NEW = "4000,178A6FD0-2F22-466D-8F2E-664E5531F50B"; + public const string GROUP_APP_MENU_EDIT_ASMED_NEW = "5000,178A6FD0-2F22-466D-8F2E-664E5531F50B"; /// Group: App Menu: Edit, Group: AsmEditor Settings - public const string GROUP_APP_MENU_EDIT_ASMED_SETTINGS = "5000,69EA4DD7-8220-43A5-9812-F1EC221AD7D8"; + public const string GROUP_APP_MENU_EDIT_ASMED_SETTINGS = "6000,69EA4DD7-8220-43A5-9812-F1EC221AD7D8"; /// Group: App Menu: Edit, Group: Hex - public const string GROUP_APP_MENU_EDIT_HEX = "6000,6D8CA476-8D3D-468E-A895-40F3A9D5A25C"; + public const string GROUP_APP_MENU_EDIT_HEX = "7000,6D8CA476-8D3D-468E-A895-40F3A9D5A25C"; /// Group: App Menu: Edit, Group: Hex MD - public const string GROUP_APP_MENU_EDIT_HEX_MD = "7000,36F0A9CA-5D14-4F56-8F64-ED3628FB5F30"; + public const string GROUP_APP_MENU_EDIT_HEX_MD = "8000,36F0A9CA-5D14-4F56-8F64-ED3628FB5F30"; /// Group: App Menu: Edit, Group: Hex MD Go To - public const string GROUP_APP_MENU_EDIT_HEX_GOTO_MD = "8000,1E0213F3-0578-43D9-A12D-14AE30EFD0EA"; + public const string GROUP_APP_MENU_EDIT_HEX_GOTO_MD = "9000,1E0213F3-0578-43D9-A12D-14AE30EFD0EA"; /// Group: App Menu: Edit, Group: Hex Copy - public const string GROUP_APP_MENU_EDIT_HEX_COPY = "9000,32791A7F-4CFC-49D2-B066-A611A9E362DB"; + public const string GROUP_APP_MENU_EDIT_HEX_COPY = "10000,32791A7F-4CFC-49D2-B066-A611A9E362DB"; /// Group: App Menu: View, Group: Options public const string GROUP_APP_MENU_VIEW_OPTS = "0,FCBA133F-F62B-4DB2-BEC9-5AE11C95873B"; @@ -439,26 +442,29 @@ public static class MenuConstants { /// Group: Context Menu, Type: Documents, Group: AsmEditor Misc public const string GROUP_CTX_DOCUMENTS_ASMED_MISC = "3000,928EDD44-E4A9-4EA9-93FF-55709943A088"; + /// Group: Context Menu, Type: Documents, Group: AsmEditor Bundle + public const string GROUP_CTX_DOCUMENTS_ASMED_BUNDLE = "4000,3740F959-0171-4DC3-9B98-1EE944EF78AE"; + /// Group: Context Menu, Type: Documents, Group: AsmEditor New - public const string GROUP_CTX_DOCUMENTS_ASMED_NEW = "4000,05FD56B0-CAF9-48E1-9CED-5221E8A13140"; + public const string GROUP_CTX_DOCUMENTS_ASMED_NEW = "5000,05FD56B0-CAF9-48E1-9CED-5221E8A13140"; /// Group: Context Menu, Type: Documents, Group: AsmEditor Settings - public const string GROUP_CTX_DOCUMENTS_ASMED_SETTINGS = "5000,2247C4DB-73B8-4926-96EB-1C16EAF4A3E4"; + public const string GROUP_CTX_DOCUMENTS_ASMED_SETTINGS = "6000,2247C4DB-73B8-4926-96EB-1C16EAF4A3E4"; /// Group: Context Menu, Type: Documents, Group: AsmEditor IL ED - public const string GROUP_CTX_DOCUMENTS_ASMED_ILED = "6000,9E0E8539-751E-47EA-A0E9-EAB3A45724E3"; + public const string GROUP_CTX_DOCUMENTS_ASMED_ILED = "7000,9E0E8539-751E-47EA-A0E9-EAB3A45724E3"; /// Group: Context Menu, Type: Documents, Group: Tokens - public const string GROUP_CTX_DOCUMENTS_TOKENS = "7000,C98101AD-1A59-42AE-B446-16545F39DC7A"; + public const string GROUP_CTX_DOCUMENTS_TOKENS = "9000,C98101AD-1A59-42AE-B446-16545F39DC7A"; /// Group: Context Menu, Type: Documents, Group: Debug RT - public const string GROUP_CTX_DOCUMENTS_DEBUGRT = "9000,9A151E30-AC16-4745-A819-24AA199E82CB"; + public const string GROUP_CTX_DOCUMENTS_DEBUGRT = "10000,9A151E30-AC16-4745-A819-24AA199E82CB"; /// Group: Context Menu, Type: Documents, Group: Debug - public const string GROUP_CTX_DOCUMENTS_DEBUG = "10000,080A553F-F066-41DC-9CC6-B4CCF2C48675"; + public const string GROUP_CTX_DOCUMENTS_DEBUG = "11000,080A553F-F066-41DC-9CC6-B4CCF2C48675"; /// Group: Context Menu, Type: Document, Group: Other - public const string GROUP_CTX_DOCUMENTS_OTHER = "11000,15776535-8A1D-4255-8C3D-331163324C7C"; + public const string GROUP_CTX_DOCUMENTS_OTHER = "12000,15776535-8A1D-4255-8C3D-331163324C7C"; /// Group: Context Menu, Type: Bookmarks, Group: Copy public const string GROUP_CTX_BOOKMARKS_COPY = "0,1633C2A1-5A65-41FF-B83D-E6B0E1B565EC";