Move ExternalToolManager to Client.Common in preparation for testing external API.

This commit is contained in:
SuuperW 2023-10-08 02:39:48 -05:00
parent bc7decff75
commit 30b3819cb1
7 changed files with 311 additions and 218 deletions

View File

@ -1,210 +1,227 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Windows.Forms;
using BizHawk.Client.Common;
using BizHawk.Common;
using BizHawk.Emulation.Common;
namespace BizHawk.Client.EmuHawk
{
public sealed class ExternalToolManager
{
public struct MenuItemInfo
{
private readonly string _asmChecksum;
private readonly string _entryPointTypeName;
private readonly ExternalToolManager _extToolMan;
private bool _skipExtToolWarning;
public readonly string AsmFilename;
public MenuItemInfo(
ExternalToolManager extToolMan,
string asmChecksum,
string asmFilename,
string entryPointTypeName)
{
_asmChecksum = asmChecksum;
_entryPointTypeName = entryPointTypeName;
_extToolMan = extToolMan;
_skipExtToolWarning = _extToolMan._config.TrustedExtTools.TryGetValue(asmFilename, out var s) && s == _asmChecksum;
AsmFilename = asmFilename;
}
public void TryLoad()
{
var success = _extToolMan._loadCallback(
/*toolPath:*/ AsmFilename,
/*customFormTypeName:*/ _entryPointTypeName,
/*skipExtToolWarning:*/ _skipExtToolWarning);
if (!success || _skipExtToolWarning) return;
_skipExtToolWarning = true;
_extToolMan._config.TrustedExtTools[AsmFilename] = _asmChecksum;
}
}
private Config _config;
private readonly Func<(string SysID, string Hash)> _getLoadedRomInfoCallback;
private readonly Func<string, string, bool, bool> _loadCallback;
private FileSystemWatcher DirectoryMonitor;
private readonly List<ToolStripMenuItem> MenuItems = new List<ToolStripMenuItem>();
internal readonly IList<string> PossibleExtToolTypeNames = new List<string>();
public ExternalToolManager(
Config config,
Func<(string SysID, string Hash)> getLoadedRomInfoCallback,
Func<string, string, bool, bool> loadCallback)
{
_getLoadedRomInfoCallback = getLoadedRomInfoCallback;
_loadCallback = loadCallback;
Restart(config);
}
public void Restart(Config config)
{
_config = config;
if (DirectoryMonitor != null)
{
DirectoryMonitor.Created -= DirectoryMonitor_Created;
DirectoryMonitor.Dispose();
}
var path = _config.PathEntries[PathEntryCollection.GLOBAL, "External Tools"].Path;
if (Directory.Exists(path))
{
DirectoryMonitor = new FileSystemWatcher(path, "*.dll")
{
IncludeSubdirectories = false,
NotifyFilter = NotifyFilters.LastAccess | NotifyFilters.LastWrite | NotifyFilters.FileName,
Filter = "*.dll"
};
DirectoryMonitor.Created += DirectoryMonitor_Created;
DirectoryMonitor.EnableRaisingEvents = true;
}
BuildToolStrip();
}
internal void BuildToolStrip()
{
MenuItems.Clear();
PossibleExtToolTypeNames.Clear();
if (DirectoryMonitor == null) return;
DirectoryInfo di = new(DirectoryMonitor.Path);
if (!di.Exists) return;
foreach (var fi in di.GetFiles("*.dll")) MenuItems.Add(GenerateToolTipFromFileName(fi.FullName));
}
/// <summary>Generates a <see cref="ToolStripMenuItem"/> from an assembly at <paramref name="fileName"/> containing an external tool.</summary>
/// <returns>a <see cref="ToolStripMenuItem"/> with its <see cref="ToolStripItem.Tag"/> containing a <see cref="MenuItemInfo"/></returns>
private ToolStripMenuItem GenerateToolTipFromFileName(string fileName)
{
if (fileName == null) throw new Exception();
var item = new ToolStripMenuItem(Path.GetFileName(fileName))
{
Enabled = false,
Image = Properties.Resources.ExclamationRed,
};
try
{
if (!OSTailoredCode.IsUnixHost) MotWHack.RemoveMOTW(fileName);
var asmBytes = File.ReadAllBytes(fileName);
var externalToolFile = Assembly.Load(asmBytes);
var entryPoint = externalToolFile.GetTypes()
.SingleOrDefault(t => typeof(IExternalToolForm).IsAssignableFrom(t) && t.GetCustomAttributes().OfType<ExternalToolAttribute>().Any());
if (entryPoint == null) throw new ExternalToolAttribute.MissingException();
var allAttrs = entryPoint.GetCustomAttributes().ToList();
var applicabilityAttrs = allAttrs.OfType<ExternalToolApplicabilityAttributeBase>().ToList();
if (applicabilityAttrs.Count > 1) throw new ExternalToolApplicabilityAttributeBase.DuplicateException();
var toolAttribute = allAttrs.OfType<ExternalToolAttribute>().First();
if (toolAttribute.LoadAssemblyFiles != null)
{
foreach (var depFilename in toolAttribute.LoadAssemblyFiles) Assembly.LoadFrom($"{_config.PathEntries[PathEntryCollection.GLOBAL, "External Tools"].Path}/{depFilename}");
}
item.Image = null; // no errors, remove error icon
var embeddedIconAttr = allAttrs.OfType<ExternalToolEmbeddedIconAttribute>().FirstOrDefault();
if (embeddedIconAttr != null)
{
var rawIcon = externalToolFile.GetManifestResourceStream(embeddedIconAttr.ResourcePath);
if (rawIcon != null) item.Image = new Bitmap(rawIcon);
}
item.Text = toolAttribute.Name;
MenuItemInfo menuItemInfo = new(
this,
asmChecksum: SHA1Checksum.ComputePrefixedHex(asmBytes),
asmFilename: fileName,
entryPointTypeName: entryPoint.FullName);
item.Tag = menuItemInfo;
item.Click += (_, _) => menuItemInfo.TryLoad();
PossibleExtToolTypeNames.Add(entryPoint.AssemblyQualifiedName);
if (applicabilityAttrs.Count is 1)
{
var (system, loadedRomHash) = _getLoadedRomInfoCallback();
if (applicabilityAttrs[0].NotApplicableTo(system))
{
item.ToolTipText = system is VSystemID.Raw.NULL
? "This tool doesn't work when no rom is loaded"
: "This tool doesn't work with this system";
return item;
}
if (applicabilityAttrs[0].NotApplicableTo(loadedRomHash, system))
{
item.ToolTipText = "This tool doesn't work with this game";
return item;
}
}
item.Enabled = true;
if (!string.IsNullOrWhiteSpace(toolAttribute.Description)) item.ToolTipText = toolAttribute.Description;
return item;
}
catch (Exception e)
{
#if DEBUG
if (e is ReflectionTypeLoadException rtle)
{
foreach (var e1 in rtle.LoaderExceptions) Console.WriteLine(e1.Message);
}
#endif
item.ToolTipText = e switch
{
BadImageFormatException => "This assembly can't be loaded, probably because it's corrupt or targets an incompatible .NET runtime.",
ExternalToolApplicabilityAttributeBase.DuplicateException => "The IExternalToolForm has conflicting applicability attributes.",
ExternalToolAttribute.MissingException => "The assembly doesn't contain a class implementing IExternalToolForm and annotated with [ExternalTool].",
ReflectionTypeLoadException => "Something went wrong while trying to load the assembly.",
_ => $"An exception of type {e.GetType().FullName} was thrown while trying to load the assembly and look for an IExternalToolForm:\n{e.Message}"
};
}
return item;
}
/// <summary>
/// This event is raised when we add a dll file into
/// the external tools path.
/// It will automatically load the assembly and add it into the list
/// </summary>
/// <param name="sender">Object that raised the event</param>
/// <param name="e">Event arguments</param>
private void DirectoryMonitor_Created(object sender, FileSystemEventArgs e)
{
MenuItems.Add(GenerateToolTipFromFileName(e.FullPath));
}
public IReadOnlyCollection<ToolStripItem> ToolStripItems
=> MenuItems;
}
}
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using BizHawk.Common;
using BizHawk.Emulation.Common;
namespace BizHawk.Client.Common
{
public sealed class ExternalToolManager
{
public struct MenuItemInfo
{
private readonly string _asmChecksum = "";
private readonly string _entryPointTypeName = "";
private readonly ExternalToolManager _extToolMan;
private bool _skipExtToolWarning = false;
public readonly string AsmFilename = "";
public readonly string Text;
public readonly Bitmap Icon = null;
public readonly string ToolTip;
public readonly bool Enabled = false;
public MenuItemInfo(
ExternalToolManager extToolMan,
string asmChecksum,
string asmFilename,
string entryPointTypeName,
string text,
Bitmap icon,
string toolTip,
bool enabled)
{
_asmChecksum = asmChecksum;
_entryPointTypeName = entryPointTypeName;
_extToolMan = extToolMan;
_skipExtToolWarning = _extToolMan._config.TrustedExtTools.TryGetValue(asmFilename, out var s) && s == _asmChecksum;
AsmFilename = asmFilename;
Text = text;
Icon = icon;
ToolTip = toolTip;
Enabled = enabled;
}
public MenuItemInfo(ExternalToolManager extToolMan, string text, string toolTip)
{
_extToolMan = extToolMan;
Text = text;
ToolTip = toolTip;
}
public void TryLoad()
{
var success = _extToolMan._loadCallback(
/*toolPath:*/ AsmFilename,
/*customFormTypeName:*/ _entryPointTypeName,
/*skipExtToolWarning:*/ _skipExtToolWarning);
if (!success || _skipExtToolWarning) return;
_skipExtToolWarning = true;
_extToolMan._config.TrustedExtTools[AsmFilename] = _asmChecksum;
}
}
private Config _config;
private readonly Func<(string SysID, string Hash)> _getLoadedRomInfoCallback;
private readonly Func<string, string, bool, bool> _loadCallback;
private FileSystemWatcher DirectoryMonitor;
private readonly List<MenuItemInfo> MenuItems = new List<MenuItemInfo>();
public readonly IList<string> PossibleExtToolTypeNames = new List<string>();
public ExternalToolManager(
Config config,
Func<(string SysID, string Hash)> getLoadedRomInfoCallback,
Func<string, string, bool, bool> loadCallback)
{
_getLoadedRomInfoCallback = getLoadedRomInfoCallback;
_loadCallback = loadCallback;
Restart(config);
}
public void Restart(Config config)
{
_config = config;
if (DirectoryMonitor != null)
{
DirectoryMonitor.Created -= DirectoryMonitor_Created;
DirectoryMonitor.Dispose();
}
var path = _config.PathEntries[PathEntryCollection.GLOBAL, "External Tools"].Path;
if (Directory.Exists(path))
{
DirectoryMonitor = new FileSystemWatcher(path, "*.dll")
{
IncludeSubdirectories = false,
NotifyFilter = NotifyFilters.LastAccess | NotifyFilters.LastWrite | NotifyFilters.FileName,
Filter = "*.dll"
};
DirectoryMonitor.Created += DirectoryMonitor_Created;
DirectoryMonitor.EnableRaisingEvents = true;
}
BuildToolStripInfo();
}
public void BuildToolStripInfo()
{
MenuItems.Clear();
PossibleExtToolTypeNames.Clear();
if (DirectoryMonitor == null) return;
DirectoryInfo di = new(DirectoryMonitor.Path);
if (!di.Exists) return;
foreach (var fi in di.GetFiles("*.dll")) MenuItems.Add(GenerateMenuItemTextFromFileName(fi.FullName));
}
private MenuItemInfo GenerateMenuItemTextFromFileName(string fileName)
{
if (fileName == null) throw new Exception();
try
{
if (!OSTailoredCode.IsUnixHost) MotWHack.RemoveMOTW(fileName);
var asmBytes = File.ReadAllBytes(fileName);
var externalToolFile = Assembly.Load(asmBytes);
var entryPoint = externalToolFile.GetTypes()
.SingleOrDefault(t => typeof(IExternalToolForm).IsAssignableFrom(t) && t.GetCustomAttributes().OfType<ExternalToolAttribute>().Any());
if (entryPoint == null) throw new ExternalToolAttribute.MissingException();
var allAttrs = entryPoint.GetCustomAttributes().ToList();
var applicabilityAttrs = allAttrs.OfType<ExternalToolApplicabilityAttributeBase>().ToList();
if (applicabilityAttrs.Count > 1) throw new ExternalToolApplicabilityAttributeBase.DuplicateException();
var toolAttribute = allAttrs.OfType<ExternalToolAttribute>().First();
if (toolAttribute.LoadAssemblyFiles != null)
{
foreach (var depFilename in toolAttribute.LoadAssemblyFiles) Assembly.LoadFrom($"{_config.PathEntries[PathEntryCollection.GLOBAL, "External Tools"].Path}/{depFilename}");
}
Bitmap image = null; // no errors, remove error icon
var embeddedIconAttr = allAttrs.OfType<ExternalToolEmbeddedIconAttribute>().FirstOrDefault();
if (embeddedIconAttr != null)
{
var rawIcon = externalToolFile.GetManifestResourceStream(embeddedIconAttr.ResourcePath);
if (rawIcon != null) image = new Bitmap(rawIcon);
}
PossibleExtToolTypeNames.Add(entryPoint.AssemblyQualifiedName);
bool enabled = true;
string toolTip = "";
if (applicabilityAttrs.Count is 1)
{
var (system, loadedRomHash) = _getLoadedRomInfoCallback();
if (applicabilityAttrs[0].NotApplicableTo(system))
{
toolTip = system is VSystemID.Raw.NULL
? "This tool doesn't work when no rom is loaded"
: "This tool doesn't work with this system";
enabled = false;
}
else if (applicabilityAttrs[0].NotApplicableTo(loadedRomHash, system))
{
toolTip = "This tool doesn't work with this game";
enabled = false;
}
}
else if (!string.IsNullOrWhiteSpace(toolAttribute.Description))
toolTip = toolAttribute.Description;
return new MenuItemInfo(
this,
asmChecksum: SHA1Checksum.ComputePrefixedHex(asmBytes),
asmFilename: fileName,
entryPointTypeName: entryPoint.FullName,
text: toolAttribute.Name,
icon: image,
toolTip: toolTip,
enabled: enabled);
}
catch (Exception e)
{
#if DEBUG
if (e is ReflectionTypeLoadException rtle)
{
foreach (var e1 in rtle.LoaderExceptions) Console.WriteLine(e1.Message);
}
#endif
string text = Path.GetFileName(fileName);
string toolTip = e switch
{
BadImageFormatException => "This assembly can't be loaded, probably because it's corrupt or targets an incompatible .NET runtime.",
ExternalToolApplicabilityAttributeBase.DuplicateException => "The IExternalToolForm has conflicting applicability attributes.",
ExternalToolAttribute.MissingException => "The assembly doesn't contain a class implementing IExternalToolForm and annotated with [ExternalTool].",
ReflectionTypeLoadException => "Something went wrong while trying to load the assembly.",
_ => $"An exception of type {e.GetType().FullName} was thrown while trying to load the assembly and look for an IExternalToolForm:\n{e.Message}"
};
return new MenuItemInfo(this, text, toolTip);
}
}
/// <summary>
/// This event is raised when we add a dll file into
/// the external tools path.
/// It will automatically load the assembly and add it into the list
/// </summary>
/// <param name="sender">Object that raised the event</param>
/// <param name="e">Event arguments</param>
private void DirectoryMonitor_Created(object sender, FileSystemEventArgs e)
{
MenuItems.Add(GenerateMenuItemTextFromFileName(e.FullPath));
}
public IReadOnlyCollection<MenuItemInfo> ToolStripItems
=> MenuItems;
}
}

View File

@ -1182,7 +1182,14 @@ namespace BizHawk.Client.EmuHawk
private void ExternalToolMenuItem_DropDownOpening(object sender, EventArgs e)
{
ExternalToolMenuItem.DropDownItems.Clear();
ExternalToolMenuItem.DropDownItems.AddRange(ExtToolManager.ToolStripItems.ToArray());
ExternalToolMenuItem.DropDownItems.AddRange(ExtToolManager.ToolStripItems.Select(static (info) =>
{
return new ToolStripMenuItem(info.Text, info.Icon, (_, _) => info.TryLoad())
{
ToolTipText = info.ToolTip,
Enabled = info.Enabled,
};
}).ToArray());
if (ExternalToolMenuItem.DropDownItems.Count == 0)
{
ExternalToolMenuItem.DropDownItems.Add("None");

View File

@ -193,7 +193,6 @@ namespace BizHawk.Client.EmuHawk
if (requestedExtToolDll != null)
{
var found = ExtToolManager.ToolStripItems.Where(static item => item.Enabled)
.Select(static item => (ExternalToolManager.MenuItemInfo) item.Tag)
.FirstOrNull(info => info.AsmFilename == requestedExtToolDll
|| Path.GetFileName(info.AsmFilename) == requestedExtToolDll
|| Path.GetFileNameWithoutExtension(info.AsmFilename) == requestedExtToolDll);
@ -4000,7 +3999,7 @@ namespace BizHawk.Client.EmuHawk
}
}
ExtToolManager.BuildToolStrip();
ExtToolManager.BuildToolStripInfo();
EmuClient.OnRomLoaded();
return true;
@ -4009,7 +4008,7 @@ namespace BizHawk.Client.EmuHawk
{
// This shows up if there's a problem
Tools.Restart(Config, Emulator, Game);
ExtToolManager.BuildToolStrip();
ExtToolManager.BuildToolStripInfo();
OnRomChanged();
return false;
}
@ -4142,7 +4141,7 @@ namespace BizHawk.Client.EmuHawk
DisplayManager.UpdateGlobals(Config, Emulator);
InputManager.SyncControls(Emulator, MovieSession, Config);
Tools.UpdateCheatRelatedTools(null, null);
ExtToolManager.BuildToolStrip();
ExtToolManager.BuildToolStripInfo();
PauseOnFrame = null;
CurrentlyOpenRom = null;
CurrentlyOpenRomArgs = null;

View File

@ -74,11 +74,10 @@ namespace BizHawk.Client.EmuHawk
if (!tsi.Enabled) continue;
ToolStripButton tsb = new() {
DisplayStyle = ToolStripItemDisplayStyle.Image,
Image = tsi.Image ?? IconMissingIcon.Value,
Image = tsi.Icon ?? IconMissingIcon.Value,
Text = tsi.Text,
};
var info = (ExternalToolManager.MenuItemInfo) tsi.Tag;
tsb.Click += (_, _) => info.TryLoad();
tsb.Click += (_, _) => tsi.TryLoad();
ToolBoxStrip.Items.Add(tsb);
}
}

View File

@ -53,6 +53,8 @@ namespace BizHawk.Client.EmuHawk
IMovieSession movieSession,
IGameInfo game)
{
_owner = owner;
_config = config;
_displayManager = displayManager;

View File

@ -0,0 +1,35 @@
using System.IO;
using System.Linq;
using System.Reflection;
using BizHawk.Client.Common;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace BizHawk.Tests.Client.Common.Api
{
[TestClass]
public class ExternalToolTests
{
[ClassInitialize]
public static void TestInitialize(TestContext context)
{
// Move our .dll to a directory by itself, so that the ExternalToolManager will only find us.
string asmName = Assembly.GetExecutingAssembly().Location;
Directory.CreateDirectory("extTools");
File.Copy(asmName, "extTools/ExternalToolTests.dll");
}
[TestMethod]
public void TestExternalToolIsFound()
{
Config config = new Config();
config.PathEntries.Paths.Find((e) => string.Equals(e.Type, "External Tools", System.StringComparison.Ordinal))!.Path = "./extTools";
string t = config.PathEntries[PathEntryCollection.GLOBAL, "External Tools"].Path;
ExternalToolManager manager = new ExternalToolManager(config, () => ("", ""), (p1, p2, p3) => true);
Assert.IsTrue(manager.ToolStripItems.Count != 0);
var item = manager.ToolStripItems.First(static (info) => info.Text == "TEST");
Assert.AreEqual("TEST", item.Text);
}
}
}

View File

@ -0,0 +1,34 @@
using BizHawk.Client.Common;
namespace BizHawk.Tests.Implementations
{
[ExternalTool("TEST")]
public class TestExternalAPI : IExternalToolForm
{
public ApiContainer? _maybeAPIContainer { get; set; }
private ApiContainer APIs
=> _maybeAPIContainer!;
private int frameCount = 0;
public bool IsActive => true;
public bool IsLoaded => true;
public bool ContainsFocus => false;
public bool AskSaveChanges() => true;
public void Close() {}
public bool Focus() => false;
public void Restart() { }
public void Show() { }
public void UpdateValues(ToolFormUpdateType type)
{
if (type == ToolFormUpdateType.PostFrame)
{
frameCount++;
}
}
}
}