embed plugin loader directly into the launcher

This commit is contained in:
zznty
2022-10-29 01:50:14 +07:00
parent 7204815c0c
commit 66d3dc2ead
53 changed files with 5689 additions and 10 deletions

View File

@@ -2,6 +2,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CringeLauncher", "CringeLauncher\CringeLauncher.csproj", "{219C897E-452D-49B5-80C4-F3008718C16A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluginLoader", "PluginLoader\PluginLoader.csproj", "{A7C22A74-56EA-4DC2-89AA-A1134BFB8497}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -12,5 +14,9 @@ Global
{219C897E-452D-49B5-80C4-F3008718C16A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{219C897E-452D-49B5-80C4-F3008718C16A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{219C897E-452D-49B5-80C4-F3008718C16A}.Release|Any CPU.Build.0 = Release|Any CPU
{A7C22A74-56EA-4DC2-89AA-A1134BFB8497}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A7C22A74-56EA-4DC2-89AA-A1134BFB8497}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A7C22A74-56EA-4DC2-89AA-A1134BFB8497}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A7C22A74-56EA-4DC2-89AA-A1134BFB8497}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -2,20 +2,25 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net6.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Lib.Harmony" Version="2.2.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="2.9.0" IncludeAssets="compile" PrivateAssets="all" />
<PackageReference Include="NLog" Version="5.0.5" />
<PackageReference Include="SpaceEngineersDedicated.ReferenceAssemblies" Version="1.201.13" IncludeAssets="compile" PrivateAssets="all" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="6.0.1" />
<PackageReference Include="System.Management" Version="6.0.0" />
<PackageReference Include="System.Private.ServiceModel" Version="4.10.0" />
<PackageReference Include="Lib.Harmony" Version="2.2.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="2.9.0" IncludeAssets="compile" PrivateAssets="all" />
<PackageReference Include="NLog" Version="5.0.5" />
<PackageReference Include="SpaceEngineersDedicated.ReferenceAssemblies" Version="1.201.13" IncludeAssets="compile" PrivateAssets="all" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="6.0.1" />
<PackageReference Include="System.Management" Version="6.0.0" />
<PackageReference Include="System.Private.ServiceModel" Version="4.10.0" />
<PackageReference Include="Torch.SixLabors.ImageSharp" Version="1.0.0-beta6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PluginLoader\PluginLoader.csproj" />
</ItemGroup>
<Target Name="Unfuckit" AfterTargets="Build">

View File

@@ -1,4 +1,5 @@
using HarmonyLib;
using System.Reflection;
using HarmonyLib;
using Sandbox;
using Sandbox.Engine.Multiplayer;
using Sandbox.Engine.Networking;
@@ -16,6 +17,7 @@ using VRage.Game;
using VRage.GameServices;
using VRage.Mod.Io;
using VRage.Platform.Windows;
using VRage.Plugins;
using VRage.Steam;
using VRage.UserInterface;
using VRageRender;
@@ -51,6 +53,10 @@ public class Launcher : IDisposable
MyVRage.Platform.System.OnThreadpoolInitialized();
InitRender();
MyFileSystem.InitUserSpecific(MyGameService.UserId.ToString());
AccessTools.MethodDelegate<Action<List<Assembly>>>(AccessTools.Method(typeof(MyPlugins), "LoadPlugins"))(new()
{
typeof(PluginLoader.Main).Assembly
});
_game = new(args);
}

View File

@@ -0,0 +1,103 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
namespace PluginLoader.Compiler;
public class RoslynCompiler
{
private readonly List<Source> source = new();
private readonly bool debugBuild;
public RoslynCompiler(bool debugBuild = false)
{
this.debugBuild = debugBuild;
}
public void Load(Stream s, string name)
{
var mem = new MemoryStream();
using (mem)
{
s.CopyTo(mem);
source.Add(new(mem, name, debugBuild));
}
}
public byte[] Compile(string assemblyName, out byte[] symbols)
{
symbols = null;
var compilation = CSharpCompilation.Create(
assemblyName,
source.Select(x => x.Tree),
RoslynReferences.EnumerateAllReferences(),
new(
OutputKind.DynamicallyLinkedLibrary,
optimizationLevel: debugBuild ? OptimizationLevel.Debug : OptimizationLevel.Release));
using (var pdb = new MemoryStream())
using (var ms = new MemoryStream())
{
// write IL code into memory
EmitResult result;
if (debugBuild)
result = compilation.Emit(ms, pdb,
embeddedTexts: source.Select(x => x.Text),
options: new(debugInformationFormat: DebugInformationFormat.PortablePdb,
pdbFilePath: Path.ChangeExtension(assemblyName, "pdb")));
else
result = compilation.Emit(ms);
if (!result.Success)
{
// handle exceptions
var failures = result.Diagnostics.Where(diagnostic =>
diagnostic.IsWarningAsError ||
diagnostic.Severity == DiagnosticSeverity.Error);
foreach (var diagnostic in failures)
{
var location = diagnostic.Location;
var source = this.source.FirstOrDefault(x => x.Tree == location.SourceTree);
LogFile.WriteLine(
$"{diagnostic.Id}: {diagnostic.GetMessage()} in file:\n{source?.Name ?? "null"} ({location.GetLineSpan().StartLinePosition})");
}
throw new("Compilation failed!");
}
if (debugBuild)
{
pdb.Seek(0, SeekOrigin.Begin);
symbols = pdb.ToArray();
}
ms.Seek(0, SeekOrigin.Begin);
return ms.ToArray();
}
}
private class Source
{
public Source(Stream s, string name, bool includeText)
{
Name = name;
var source = SourceText.From(s, canBeEmbedded: includeText);
if (includeText)
{
Text = EmbeddedText.FromSource(name, source);
Tree = CSharpSyntaxTree.ParseText(source, new(LanguageVersion.Latest), name);
}
else
{
Tree = CSharpSyntaxTree.ParseText(source, new(LanguageVersion.Latest));
}
}
public string Name { get; }
public SyntaxTree Tree { get; }
public EmbeddedText Text { get; }
}
}

View File

@@ -0,0 +1,135 @@
using System.Reflection;
using System.Text;
using HarmonyLib;
using Microsoft.CodeAnalysis;
namespace PluginLoader.Compiler;
public static class RoslynReferences
{
private static readonly Dictionary<string, MetadataReference> allReferences = new();
private static readonly HashSet<string> referenceBlacklist = new(new[] { "System.ValueTuple" });
public static void GenerateAssemblyList()
{
if (allReferences.Count > 0)
return;
var harmonyInfo = typeof(Harmony).Assembly.GetName();
var loadedAssemblies = new Stack<Assembly>(AppDomain.CurrentDomain.GetAssemblies().Where(IsValidReference));
var sb = new StringBuilder();
sb.AppendLine();
var line = "===================================";
sb.AppendLine(line);
sb.AppendLine("Assembly References");
sb.AppendLine(line);
try
{
foreach (var a in loadedAssemblies)
{
// Prevent other Harmony versions from being loaded
var name = a.GetName();
if (name.Name == harmonyInfo.Name && name.Version != harmonyInfo.Version)
{
LogFile.WriteLine(
$"WARNING: Multiple Harmony assemblies are loaded. Plugin Loader is using {harmonyInfo} but found {name}");
continue;
}
AddAssemblyReference(a);
sb.AppendLine(a.FullName);
}
sb.AppendLine(line);
while (loadedAssemblies.Count > 0)
{
var a = loadedAssemblies.Pop();
foreach (var name in a.GetReferencedAssemblies())
{
// Prevent other Harmony versions from being loaded
if (name.Name == harmonyInfo.Name && name.Version != harmonyInfo.Version)
{
LogFile.WriteLine(
$"WARNING: Multiple Harmony assemblies are loaded. Plugin Loader is using {harmonyInfo} but found {name}");
continue;
}
if (!ContainsReference(name) && TryLoadAssembly(name, out var aRef) && IsValidReference(aRef))
{
AddAssemblyReference(aRef);
sb.AppendLine(name.FullName);
loadedAssemblies.Push(aRef);
}
}
}
sb.AppendLine(line);
}
catch (Exception e)
{
sb.Append("Error: ").Append(e).AppendLine();
}
LogFile.WriteLine(sb.ToString(), false);
}
private static bool ContainsReference(AssemblyName name)
{
return allReferences.ContainsKey(name.Name);
}
private static bool TryLoadAssembly(AssemblyName name, out Assembly aRef)
{
try
{
aRef = Assembly.Load(name);
return true;
}
catch (IOException)
{
aRef = null;
return false;
}
}
private static void AddAssemblyReference(Assembly a)
{
var name = a.GetName().Name;
if (!allReferences.ContainsKey(name))
allReferences.Add(name, MetadataReference.CreateFromFile(a.Location));
}
public static IEnumerable<MetadataReference> EnumerateAllReferences()
{
return allReferences.Values;
}
private static bool IsValidReference(Assembly a)
{
return !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location) && !referenceBlacklist.Contains(a.GetName().Name);
}
public static void LoadReference(string name)
{
try
{
var aName = new AssemblyName(name);
if (!allReferences.ContainsKey(aName.Name))
{
var a = Assembly.Load(aName);
LogFile.WriteLine("Reference added at runtime: " + a.FullName);
MetadataReference aRef = MetadataReference.CreateFromFile(a.Location);
allReferences[a.GetName().Name] = aRef;
}
}
catch (IOException)
{
LogFile.WriteLine("WARNING: Unable to find the assembly '" + name + "'!");
}
}
}

View File

@@ -0,0 +1,159 @@
using System.IO.Compression;
using System.Reflection;
using System.Text;
using System.Xml.Serialization;
using PluginLoader.Compiler;
using PluginLoader.Network;
using ProtoBuf;
using Sandbox.Graphics.GUI;
namespace PluginLoader.Data;
[ProtoContract]
public class GitHubPlugin : PluginData
{
private const string pluginFile = "plugin.dll";
private const string commitHashFile = "commit.sha1";
private string cacheDir, assemblyName;
public GitHubPlugin()
{
Status = PluginStatus.None;
}
public override string Source => "GitHub";
[ProtoMember(1)] public string Commit { get; set; }
[ProtoMember(2)]
[XmlArray]
[XmlArrayItem("Directory")]
public string[] SourceDirectories { get; set; }
public void Init(string mainDirectory)
{
var nameArgs = Id.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
if (nameArgs.Length != 2)
throw new("Invalid GitHub name: " + Id);
if (SourceDirectories != null)
for (var i = SourceDirectories.Length - 1; i >= 0; i--)
{
var path = SourceDirectories[i].Replace('\\', '/').TrimStart('/');
if (path.Length == 0)
{
SourceDirectories.RemoveAtFast(i);
continue;
}
if (path[path.Length - 1] != '/')
path += '/';
SourceDirectories[i] = path;
}
assemblyName = MakeSafeString(nameArgs[1]);
cacheDir = Path.Combine(mainDirectory, "GitHub", nameArgs[0], nameArgs[1]);
}
private string MakeSafeString(string s)
{
var sb = new StringBuilder();
foreach (var ch in s)
if (char.IsLetterOrDigit(ch))
sb.Append(ch);
else
sb.Append('_');
return sb.ToString();
}
public override Assembly GetAssembly()
{
if (!Directory.Exists(cacheDir))
Directory.CreateDirectory(cacheDir);
Assembly a;
var dllFile = Path.Combine(cacheDir, pluginFile);
var commitFile = Path.Combine(cacheDir, commitHashFile);
if (!File.Exists(dllFile) || !File.Exists(commitFile) || File.ReadAllText(commitFile) != Commit)
{
var lbl = Main.Instance.Splash;
lbl.SetText($"Downloading '{FriendlyName}'");
var data = CompileFromSource(x => lbl.SetBarValue(x));
File.WriteAllBytes(dllFile, data);
File.WriteAllText(commitFile, Commit);
Status = PluginStatus.Updated;
lbl.SetText($"Compiled '{FriendlyName}'");
a = Assembly.Load(data);
}
else
{
a = Assembly.LoadFile(dllFile);
}
Version = a.GetName().Version;
return a;
}
public byte[] CompileFromSource(Action<float> callback = null)
{
var compiler = new RoslynCompiler();
using (var s = GitHub.DownloadRepo(Id, Commit, out var fileName))
using (var zip = new ZipArchive(s))
{
callback?.Invoke(0);
for (var i = 0; i < zip.Entries.Count; i++)
{
var entry = zip.Entries[i];
CompileFromSource(compiler, entry);
callback?.Invoke(i / (float)zip.Entries.Count);
}
callback?.Invoke(1);
}
return compiler.Compile(assemblyName + '_' + Path.GetRandomFileName(), out _);
}
private void CompileFromSource(RoslynCompiler compiler, ZipArchiveEntry entry)
{
if (AllowedZipPath(entry.FullName))
using (var entryStream = entry.Open())
{
compiler.Load(entryStream, entry.FullName);
}
}
private bool AllowedZipPath(string path)
{
if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
return false;
if (SourceDirectories == null || SourceDirectories.Length == 0)
return true;
path = RemoveRoot(path); // Make the base of the path the root of the repository
foreach (var dir in SourceDirectories)
if (path.StartsWith(dir, StringComparison.Ordinal))
return true;
return false;
}
private string RemoveRoot(string path)
{
path = path.Replace('\\', '/').TrimStart('/');
var index = path.IndexOf('/');
if (index >= 0 && index + 1 < path.Length)
return path.Substring(index + 1);
return path;
}
public override void Show()
{
MyGuiSandbox.OpenUrl("https://github.com/" + Id, UrlOpenMode.SteamOrExternalWithConfirm);
}
}

View File

@@ -0,0 +1,7 @@
namespace PluginLoader.Data;
public interface ISteamItem
{
string Id { get; }
ulong WorkshopId { get; }
}

View File

@@ -0,0 +1,268 @@
using System.Diagnostics;
using System.Reflection;
using System.Text;
using System.Xml.Serialization;
using PluginLoader.Compiler;
using PluginLoader.GUI;
using Sandbox.Graphics.GUI;
using VRage;
namespace PluginLoader.Data;
public class LocalFolderPlugin : PluginData
{
private const string XmlDataType = "Xml files (*.xml)|*.xml|All files (*.*)|*.*";
private const int GitTimeout = 10000;
private string[] sourceDirectories;
public LocalFolderPlugin(Config settings)
{
Id = settings.Folder;
FriendlyName = Path.GetFileName(Id);
Status = PluginStatus.None;
FolderSettings = settings;
DeserializeFile(settings.DataFile);
}
private LocalFolderPlugin(string folder)
{
Id = folder;
Status = PluginStatus.None;
FolderSettings = new()
{
Folder = folder
};
}
public override string Source => MyTexts.GetString(MyCommonTexts.Local);
public Config FolderSettings { get; }
public override Assembly GetAssembly()
{
if (Directory.Exists(Id))
{
var compiler = new RoslynCompiler(FolderSettings.DebugBuild);
var hasFile = false;
var sb = new StringBuilder();
sb.Append("Compiling files from ").Append(Id).Append(":").AppendLine();
foreach (var file in GetProjectFiles(Id))
using (var fileStream = File.OpenRead(file))
{
hasFile = true;
var name = file.Substring(Id.Length + 1, file.Length - (Id.Length + 1));
sb.Append(name).Append(", ");
compiler.Load(fileStream, file);
}
if (hasFile)
{
sb.Length -= 2;
LogFile.WriteLine(sb.ToString());
}
else
{
throw new IOException("No files were found in the directory specified.");
}
var data = compiler.Compile(FriendlyName + '_' + Path.GetRandomFileName(), out var symbols);
var a = Assembly.Load(data, symbols);
Version = a.GetName().Version;
return a;
}
throw new DirectoryNotFoundException("Unable to find directory '" + Id + "'");
}
private IEnumerable<string> GetProjectFiles(string folder)
{
string gitError = null;
try
{
var p = new Process();
// Redirect the output stream of the child process.
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardOutput = true;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.FileName = "git";
p.StartInfo.Arguments = "ls-files --cached --others --exclude-standard";
p.StartInfo.WorkingDirectory = folder;
p.Start();
// Do not wait for the child process to exit before
// reading to the end of its redirected stream.
// Read the output stream first and then wait.
var gitOutput = p.StandardOutput.ReadToEnd();
gitError = p.StandardError.ReadToEnd();
if (!p.WaitForExit(GitTimeout))
{
p.Kill();
throw new TimeoutException("Git operation timed out.");
}
if (p.ExitCode == 0)
{
var files = gitOutput.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
return files.Where(x => x.EndsWith(".cs") && IsValidProjectFile(x))
.Select(x => Path.Combine(folder, x.Trim().Replace('/', Path.DirectorySeparatorChar)))
.Where(x => File.Exists(x));
}
var sb = new StringBuilder("An error occurred while checking git for project files.").AppendLine();
if (!string.IsNullOrWhiteSpace(gitError))
{
sb.AppendLine("Git output: ");
sb.Append(gitError).AppendLine();
}
LogFile.WriteLine(sb.ToString());
}
catch (Exception e)
{
var sb = new StringBuilder("An error occurred while checking git for project files.").AppendLine();
if (!string.IsNullOrWhiteSpace(gitError))
{
sb.AppendLine(" Git output: ");
sb.Append(gitError).AppendLine();
}
sb.AppendLine("Exception: ");
sb.Append(e).AppendLine();
LogFile.WriteLine(sb.ToString());
}
var sep = Path.DirectorySeparatorChar;
return Directory.EnumerateFiles(folder, "*.cs", SearchOption.AllDirectories)
.Where(x => !x.Contains(sep + "bin" + sep) && !x.Contains(sep + "obj" + sep) &&
IsValidProjectFile(x));
}
private bool IsValidProjectFile(string file)
{
if (sourceDirectories == null || sourceDirectories.Length == 0)
return true;
file = file.Replace('\\', '/');
foreach (var dir in sourceDirectories)
if (file.StartsWith(dir))
return true;
return false;
}
public override string ToString()
{
return Id;
}
public override void Show()
{
var folder = Path.GetFullPath(Id);
if (Directory.Exists(folder))
Process.Start("explorer.exe", $"\"{folder}\"");
}
public override bool OpenContextMenu(MyGuiControlContextMenu menu)
{
menu.Clear();
menu.AddItem(new("Remove"));
menu.AddItem(new("Load data file"));
if (FolderSettings.DebugBuild)
menu.AddItem(new("Switch to release build"));
else
menu.AddItem(new("Switch to debug build"));
return true;
}
public override void ContextMenuClicked(MyGuiScreenPluginConfig screen, MyGuiControlContextMenu.EventArgs args)
{
switch (args.ItemIndex)
{
case 0:
Main.Instance.Config.PluginFolders.Remove(Id);
screen.RemovePlugin(this);
screen.RequireRestart();
break;
case 1:
LoaderTools.OpenFileDialog("Open an xml data file", Path.GetDirectoryName(FolderSettings.DataFile),
XmlDataType, file => DeserializeFile(file, screen));
break;
case 2:
FolderSettings.DebugBuild = !FolderSettings.DebugBuild;
screen.RequireRestart();
break;
}
}
// Deserializes a file and refreshes the plugin screen
private void DeserializeFile(string file, MyGuiScreenPluginConfig screen = null)
{
if (!File.Exists(file))
return;
try
{
var xml = new XmlSerializer(typeof(PluginData));
using (var reader = File.OpenText(file))
{
var resultObj = xml.Deserialize(reader);
if (resultObj.GetType() != typeof(GitHubPlugin)) throw new("Xml file is not of type GitHubPlugin!");
var github = (GitHubPlugin)resultObj;
github.Init(LoaderTools.PluginsDir);
FriendlyName = github.FriendlyName;
Tooltip = github.Tooltip;
Author = github.Author;
Description = github.Description;
sourceDirectories = github.SourceDirectories;
FolderSettings.DataFile = file;
if (screen != null && screen.Visible && screen.IsOpened)
screen.RefreshSidePanel();
}
}
catch (Exception e)
{
LogFile.WriteLine("Error while reading the xml file: " + e);
}
}
public static void CreateNew(Action<LocalFolderPlugin> onComplete)
{
LoaderTools.OpenFolderDialog("Open the root of your project", LoaderTools.PluginsDir, folder =>
{
if (Main.Instance.List.Contains(folder))
{
MyGuiSandbox.CreateMessageBox(MyMessageBoxStyleEnum.Error,
messageText: new("That folder already exists in the list!"));
return;
}
var plugin = new LocalFolderPlugin(folder);
LoaderTools.OpenFileDialog("Open the xml data file", folder, XmlDataType, file =>
{
plugin.DeserializeFile(file);
onComplete(plugin);
});
});
}
public class Config
{
public Config()
{
}
public Config(string folder, string dataFile)
{
Folder = folder;
DataFile = dataFile;
}
public string Folder { get; set; }
public string DataFile { get; set; }
public bool DebugBuild { get; set; } = true;
public bool Valid => Directory.Exists(Folder) && File.Exists(DataFile);
}
}

View File

@@ -0,0 +1,92 @@
using System.Diagnostics;
using System.Reflection;
using Sandbox.Graphics.GUI;
using VRage;
namespace PluginLoader.Data;
public class LocalPlugin : PluginData
{
private LocalPlugin()
{
}
public LocalPlugin(string dll)
{
Id = dll;
Status = PluginStatus.None;
}
public override string Source => MyTexts.GetString(MyCommonTexts.Local);
public override string Id
{
get => base.Id;
set
{
base.Id = value;
if (File.Exists(value))
FriendlyName = Path.GetFileName(value);
}
}
public override Assembly GetAssembly()
{
if (File.Exists(Id))
{
AppDomain.CurrentDomain.AssemblyResolve += LoadFromSameFolder;
var a = Assembly.LoadFile(Id);
Version = a.GetName().Version;
return a;
}
return null;
}
public override string ToString()
{
return Id;
}
public override void Show()
{
var file = Path.GetFullPath(Id);
if (File.Exists(file))
Process.Start("explorer.exe", $"/select, \"{file}\"");
}
private Assembly LoadFromSameFolder(object sender, ResolveEventArgs args)
{
if (args.RequestingAssembly.IsDynamic)
return null;
if (args.Name.Contains("0Harmony") || args.Name.Contains("SEPluginManager"))
return null;
var location = args.RequestingAssembly.Location;
if (string.IsNullOrWhiteSpace(location) || !Path.GetFullPath(location)
.StartsWith(Path.GetDirectoryName(Id),
StringComparison.OrdinalIgnoreCase))
return null;
var folderPath = Path.GetDirectoryName(location);
var assemblyPath = Path.Combine(folderPath, new AssemblyName(args.Name).Name + ".dll");
if (!File.Exists(assemblyPath))
return null;
var assembly = Assembly.LoadFile(assemblyPath);
LogFile.WriteLine("Resolving " + assembly.GetName().Name + " for " + args.RequestingAssembly.FullName);
var main = Main.Instance;
if (!main.Config.IsEnabled(assemblyPath))
main.List.Remove(assemblyPath);
return assembly;
}
public override void GetDescriptionText(MyGuiControlMultilineText textbox)
{
textbox.Visible = false;
textbox.Clear();
}
}

View File

@@ -0,0 +1,100 @@
using System.Reflection;
using System.Xml.Serialization;
using ProtoBuf;
using Sandbox.Graphics.GUI;
using VRage.Game;
using VRage.GameServices;
namespace PluginLoader.Data;
[ProtoContract]
public class ModPlugin : PluginData, ISteamItem
{
private bool isLegacy;
private string modLocation;
public override string Source => "Mod";
[ProtoMember(1)]
[XmlArray]
[XmlArrayItem("Id")]
public ulong[] DependencyIds { get; set; } = new ulong[0];
[XmlIgnore] public ModPlugin[] Dependencies { get; set; } = new ModPlugin[0];
public string ModLocation
{
get
{
if (modLocation != null)
return modLocation;
modLocation = Path.Combine(Path.GetFullPath(@"..\..\..\workshop\content\244850\"), WorkshopId.ToString());
if (Directory.Exists(modLocation) && !Directory.Exists(Path.Combine(modLocation, "Data")))
{
var legacyFile = Directory.EnumerateFiles(modLocation, "*_legacy.bin").FirstOrDefault();
if (legacyFile != null)
{
isLegacy = true;
modLocation = legacyFile;
}
}
return modLocation;
}
}
public bool Exists => Directory.Exists(ModLocation) || (isLegacy && File.Exists(modLocation));
[XmlIgnore] public ulong WorkshopId { get; private set; }
public override string Id
{
get => base.Id;
set
{
base.Id = value;
WorkshopId = ulong.Parse(Id);
}
}
public override Assembly GetAssembly()
{
return null;
}
public override bool TryLoadAssembly(out Assembly a)
{
a = null;
return false;
}
public override void Show()
{
MyGuiSandbox.OpenUrl("https://steamcommunity.com/workshop/filedetails/?id=" + Id,
UrlOpenMode.SteamOrExternalWithConfirm);
}
public MyObjectBuilder_Checkpoint.ModItem GetModItem()
{
var modItem = new MyObjectBuilder_Checkpoint.ModItem(WorkshopId, "Steam");
modItem.SetModData(new WorkshopItem(ModLocation));
return modItem;
}
public MyModContext GetModContext()
{
var modContext = new MyModContext();
modContext.Init(GetModItem());
modContext.Init(WorkshopId.ToString(), null, ModLocation);
return modContext;
}
private class WorkshopItem : MyWorkshopItem
{
public WorkshopItem(string folder)
{
Folder = folder;
}
}
}

View File

@@ -0,0 +1,223 @@
using System.Diagnostics;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Xml.Serialization;
using PluginLoader.GUI;
using ProtoBuf;
using Sandbox.Graphics.GUI;
using VRage;
using VRage.Utils;
namespace PluginLoader.Data;
[XmlInclude(typeof(WorkshopPlugin))]
[XmlInclude(typeof(SEPMPlugin))]
[XmlInclude(typeof(GitHubPlugin))]
[XmlInclude(typeof(ModPlugin))]
[ProtoContract]
[ProtoInclude(100, typeof(SteamPlugin))]
[ProtoInclude(103, typeof(GitHubPlugin))]
[ProtoInclude(104, typeof(ModPlugin))]
public abstract class PluginData : IEquatable<PluginData>
{
public abstract string Source { get; }
[XmlIgnore] public Version Version { get; protected set; }
[XmlIgnore] public virtual PluginStatus Status { get; set; } = PluginStatus.None;
public virtual string StatusString
{
get
{
switch (Status)
{
case PluginStatus.PendingUpdate:
return "Pending Update";
case PluginStatus.Updated:
return "Updated";
case PluginStatus.Error:
return "Error!";
case PluginStatus.Blocked:
return "Not whitelisted!";
default:
return "";
}
}
}
[XmlIgnore] public bool IsLocal => Source == MyTexts.GetString(MyCommonTexts.Local);
[ProtoMember(1)] public virtual string Id { get; set; }
[ProtoMember(2)] public string FriendlyName { get; set; } = "Unknown";
[ProtoMember(3)] public bool Hidden { get; set; } = false;
[ProtoMember(4)] public string GroupId { get; set; }
[ProtoMember(5)] public string Tooltip { get; set; }
[ProtoMember(6)] public string Author { get; set; }
[ProtoMember(7)] public string Description { get; set; }
[XmlIgnore] public List<PluginData> Group { get; } = new();
[XmlIgnore] public bool Enabled => Main.Instance.Config.IsEnabled(Id);
public bool Equals(PluginData other)
{
return other != null &&
Id == other.Id;
}
public abstract Assembly GetAssembly();
public virtual bool TryLoadAssembly(out Assembly a)
{
if (Status == PluginStatus.Error)
{
a = null;
return false;
}
try
{
// Get the file path
a = GetAssembly();
if (Status == PluginStatus.Blocked)
return false;
if (a == null)
{
LogFile.WriteLine("Failed to load " + ToString());
Error();
return false;
}
// Precompile the entire assembly in order to force any missing method exceptions
LogFile.WriteLine("Precompiling " + a);
LoaderTools.Precompile(a);
return true;
}
catch (Exception e)
{
var name = ToString();
LogFile.WriteLine($"Failed to load {name} because of an error: " + e);
if (e is MissingMemberException)
LogFile.WriteLine($"Is {name} up to date?");
if (e is NotSupportedException && e.Message.Contains("loadFromRemoteSources"))
Error($"The plugin {name} was blocked by windows. Please unblock the file in the dll file properties.");
else
Error();
a = null;
return false;
}
}
public override bool Equals(object obj)
{
return Equals(obj as PluginData);
}
public override int GetHashCode()
{
return 2108858624 + EqualityComparer<string>.Default.GetHashCode(Id);
}
public static bool operator ==(PluginData left, PluginData right)
{
return EqualityComparer<PluginData>.Default.Equals(left, right);
}
public static bool operator !=(PluginData left, PluginData right)
{
return !(left == right);
}
public override string ToString()
{
return Id + '|' + FriendlyName;
}
public void Error(string msg = null)
{
Status = PluginStatus.Error;
if (msg == null)
msg =
$"The plugin '{this}' caused an error. It is recommended that you disable this plugin and restart. The game may be unstable beyond this point. See loader.log or the game log for details.";
var file = MyLog.Default.GetFilePath();
if (File.Exists(file) && file.EndsWith(".log"))
{
MyLog.Default.Flush();
msg += "\n\nWould you like to open the game log?";
var result = LoaderTools.ShowMessageBox(msg, "Plugin Loader", MessageBoxButtons.YesNo,
MessageBoxIcon.Error);
if (result == DialogResult.Yes)
Process.Start(file);
}
else
{
LoaderTools.ShowMessageBox(msg, "Plugin Loader", MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
protected void ErrorSecurity(string hash)
{
Status = PluginStatus.Blocked;
LoaderTools.ShowMessageBox($"Unable to load the plugin {this} because it is not whitelisted!",
"Plugin Loader", MessageBoxButtons.OK, MessageBoxIcon.Error);
LogFile.WriteLine("Error: " + this + " with an sha256 of " + hash + " is not on the whitelist!");
}
public abstract void Show();
public virtual void GetDescriptionText(MyGuiControlMultilineText textbox)
{
textbox.Visible = true;
textbox.Clear();
if (string.IsNullOrEmpty(Description))
{
if (string.IsNullOrEmpty(Tooltip))
textbox.AppendText("No description");
else
textbox.AppendText(CapLength(Tooltip, 1000));
return;
}
var text = CapLength(Description, 1000);
var textStart = 0;
foreach (Match m in Regex.Matches(text, @"https?:\/\/(www\.)?[\w-.]{2,256}\.[a-z]{2,4}\b[\w-.@:%\+~#?&//=]*"))
{
var textLen = m.Index - textStart;
if (textLen > 0)
textbox.AppendText(text.Substring(textStart, textLen));
textbox.AppendLink(m.Value, m.Value);
textStart = m.Index + m.Length;
}
if (textStart < text.Length)
textbox.AppendText(text.Substring(textStart));
}
private string CapLength(string s, int len)
{
if (s.Length > len)
return s.Substring(0, len);
return s;
}
public virtual bool OpenContextMenu(MyGuiControlContextMenu menu)
{
return false;
}
public virtual void ContextMenuClicked(MyGuiScreenPluginConfig screen, MyGuiControlContextMenu.EventArgs args)
{
}
}

View File

@@ -0,0 +1,10 @@
namespace PluginLoader.Data;
public enum PluginStatus
{
None,
PendingUpdate,
Updated,
Error,
Blocked
}

View File

@@ -0,0 +1,45 @@
using System.IO.Compression;
using ProtoBuf;
namespace PluginLoader.Data;
[ProtoContract]
public class SEPMPlugin : SteamPlugin
{
private const string NameFile = "name.txt";
private string dataFolder;
protected SEPMPlugin()
{
}
public override string Source => "SEPM";
protected override string HashFile => "sepm-plugin.txt";
protected override void CheckForUpdates()
{
dataFolder = Path.Combine(root, "sepm-plugin");
if (Directory.Exists(dataFolder))
base.CheckForUpdates();
else
Status = PluginStatus.PendingUpdate;
}
protected override void ApplyUpdate()
{
if (Directory.Exists(dataFolder))
Directory.Delete(dataFolder, true);
ZipFile.ExtractToDirectory(sourceFile, dataFolder);
}
protected override string GetAssemblyFile()
{
if (!Directory.Exists(dataFolder))
return null;
return Directory.EnumerateFiles(dataFolder, "*.dll")
.Where(s => !s.Equals("0Harmony.dll", StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
}
}

View File

@@ -0,0 +1,105 @@
using System.Reflection;
using System.Xml.Serialization;
using ProtoBuf;
using Sandbox.Graphics.GUI;
namespace PluginLoader.Data;
[ProtoContract]
[ProtoInclude(101, typeof(SEPMPlugin))]
[ProtoInclude(102, typeof(WorkshopPlugin))]
public abstract class SteamPlugin : PluginData, ISteamItem
{
protected string root, sourceFile, hashFile;
[XmlArray] [ProtoMember(1)] public string[] AllowedHashes { get; set; }
protected abstract string HashFile { get; }
[XmlIgnore] public ulong WorkshopId { get; private set; }
public override string Id
{
get => base.Id;
set
{
base.Id = value;
WorkshopId = ulong.Parse(Id);
}
}
public void Init(string sourceFile)
{
Status = PluginStatus.None;
this.sourceFile = sourceFile;
root = Path.GetDirectoryName(sourceFile);
hashFile = Path.Combine(root, HashFile);
CheckForUpdates();
}
protected virtual void CheckForUpdates()
{
if (File.Exists(hashFile))
{
var oldHash = File.ReadAllText(hashFile);
var newHash = LoaderTools.GetHash1(sourceFile);
if (oldHash != newHash)
Status = PluginStatus.PendingUpdate;
}
else
{
Status = PluginStatus.PendingUpdate;
}
}
public override Assembly GetAssembly()
{
if (Status == PluginStatus.PendingUpdate)
{
LogFile.WriteLine("Updating " + this);
ApplyUpdate();
if (Status == PluginStatus.PendingUpdate)
{
File.WriteAllText(hashFile, LoaderTools.GetHash1(sourceFile));
Status = PluginStatus.Updated;
}
else
{
return null;
}
}
var dll = GetAssemblyFile();
if (dll == null || !File.Exists(dll))
return null;
if (!VerifyAllowed(dll))
return null;
var a = Assembly.LoadFile(dll);
Version = a.GetName().Version;
return a;
}
protected abstract void ApplyUpdate();
protected abstract string GetAssemblyFile();
public override void Show()
{
MyGuiSandbox.OpenUrl("https://steamcommunity.com/workshop/filedetails/?id=" + Id,
UrlOpenMode.SteamOrExternalWithConfirm);
}
private bool VerifyAllowed(string dll)
{
if (AllowedHashes == null || AllowedHashes.Length == 0)
return true;
var hash = LoaderTools.GetHash256(dll);
foreach (var s in AllowedHashes)
if (s == hash)
return true;
ErrorSecurity(hash);
return false;
}
}

View File

@@ -0,0 +1,43 @@
using ProtoBuf;
using VRage;
namespace PluginLoader.Data;
[ProtoContract]
public class WorkshopPlugin : SteamPlugin
{
private string assembly;
protected WorkshopPlugin()
{
}
public override string Source => MyTexts.GetString(MyCommonTexts.Workshop);
protected override string HashFile => "hash.txt";
protected override void CheckForUpdates()
{
assembly = Path.Combine(root, Path.GetFileNameWithoutExtension(sourceFile) + ".dll");
var found = false;
foreach (var dll in Directory.EnumerateFiles(root, "*.dll"))
if (dll == assembly)
found = true;
else
File.Delete(dll);
if (!found)
Status = PluginStatus.PendingUpdate;
else
base.CheckForUpdates();
}
protected override void ApplyUpdate()
{
File.Copy(sourceFile, assembly, true);
}
protected override string GetAssemblyFile()
{
return assembly;
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using Sandbox;
using Sandbox.Graphics.GUI;
using VRage.Utils;
using VRageMath;
namespace PluginLoader.GUI;
public static class ConfirmationDialog
{
public static MyGuiScreenMessageBox CreateMessageBox(
MyMessageBoxStyleEnum styleEnum = MyMessageBoxStyleEnum.Error,
MyMessageBoxButtonsType buttonType = MyMessageBoxButtonsType.OK,
StringBuilder messageText = null,
StringBuilder messageCaption = null,
MyStringId? okButtonText = null,
MyStringId? cancelButtonText = null,
MyStringId? yesButtonText = null,
MyStringId? noButtonText = null,
Action<MyGuiScreenMessageBox.ResultEnum> callback = null,
int timeoutInMiliseconds = 0,
MyGuiScreenMessageBox.ResultEnum focusedResult = MyGuiScreenMessageBox.ResultEnum.YES,
bool canHideOthers = true,
Vector2? size = null,
bool useOpacity = true,
Vector2? position = null,
bool focusable = true,
bool canBeHidden = false,
Action onClosing = null)
{
var num1 = (int)styleEnum;
var num2 = (int)buttonType;
var messageText1 = messageText;
var messageCaption1 = messageCaption;
var nullable = okButtonText;
var okButtonText1 = nullable ?? MyCommonTexts.Ok;
nullable = cancelButtonText;
var cancelButtonText1 = nullable ?? MyCommonTexts.Cancel;
nullable = yesButtonText;
var yesButtonText1 = nullable ?? MyCommonTexts.Yes;
nullable = noButtonText;
var noButtonText1 = nullable ?? MyCommonTexts.No;
var callback1 = callback;
var timeoutInMiliseconds1 = timeoutInMiliseconds;
var num3 = (int)focusedResult;
var num4 = canHideOthers ? 1 : 0;
var size1 = size;
var num5 = useOpacity ? MySandboxGame.Config.UIBkOpacity : 1.0;
var num6 = useOpacity ? MySandboxGame.Config.UIOpacity : 1.0;
var position1 = position;
var num7 = focusable ? 1 : 0;
var num8 = canBeHidden ? 1 : 0;
var onClosing1 = onClosing;
var dlg = new MyGuiScreenMessageBox((MyMessageBoxStyleEnum)num1, (MyMessageBoxButtonsType)num2, messageText1,
messageCaption1, okButtonText1, cancelButtonText1, yesButtonText1,
noButtonText1, callback1, timeoutInMiliseconds1,
(MyGuiScreenMessageBox.ResultEnum)num3, num4 != 0, size1, (float)num5,
(float)num6, position1, num7 != 0, num8 != 0, onClosing1);
if (dlg.Controls.GetControlByName("MyGuiControlMultilineText") is MyGuiControlMultilineText text)
text.TextAlign = MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_CENTER;
return dlg;
}
}

View File

@@ -0,0 +1,81 @@
using Sandbox.Graphics;
using Sandbox.Graphics.GUI;
using VRage.Utils;
using VRageMath;
namespace PluginLoader.GUI.GuiControls;
// From Sandbox.Game.Screens.Helpers.MyGuiControlRating
internal class RatingControl : MyGuiControlBase
{
private readonly float m_space = 8f;
public string EmptyTexture = "Textures\\GUI\\Icons\\Rating\\NoStar.png";
public string FilledTexture = "Textures\\GUI\\Icons\\Rating\\FullStar.png";
public string HalfFilledTexture = "Textures\\GUI\\Icons\\Rating\\HalfStar.png";
private int m_maxValue;
private readonly Vector2 m_textureSize = new(32f);
public RatingControl(int value = 0, int maxValue = 10)
{
Value = value;
m_maxValue = maxValue;
BackgroundTexture = null;
ColorMask = Vector4.One;
}
public int MaxValue
{
get => m_maxValue;
set
{
m_maxValue = value;
RecalculateSize();
}
}
public int Value { get; set; }
private void RecalculateSize()
{
var vector = MyGuiManager.GetHudNormalizedSizeFromPixelSize(m_textureSize) * new Vector2(0.75f, 1f);
var hudNormalizedSizeFromPixelSize = MyGuiManager.GetHudNormalizedSizeFromPixelSize(new(m_space * 0.75f, 0f));
Size = new((vector.X + hudNormalizedSizeFromPixelSize.X) * m_maxValue, vector.Y);
}
public float GetWidth()
{
var num = MyGuiManager.GetHudNormalizedSizeFromPixelSize(m_textureSize).X * 0.75f;
var num2 = MyGuiManager.GetHudNormalizedSizeFromPixelSize(new(m_space * 0.75f, 0f)).X;
return (num + num2) * MaxValue / 2f;
}
public override void Draw(float transitionAlpha, float backgroundTransitionAlpha)
{
base.Draw(transitionAlpha, backgroundTransitionAlpha);
if (MaxValue <= 0) return;
var normalizedSize = MyGuiManager.GetHudNormalizedSizeFromPixelSize(m_textureSize) * new Vector2(0.75f, 1f);
var hudNormalizedSizeFromPixelSize = MyGuiManager.GetHudNormalizedSizeFromPixelSize(new(m_space * 0.75f, 0f));
var vector = GetPositionAbsoluteTopLeft() + new Vector2(0f, (Size.Y - normalizedSize.Y) / 2f);
var vector2 = new Vector2((normalizedSize.X + hudNormalizedSizeFromPixelSize.X) * 0.5f, normalizedSize.Y);
for (var i = 0; i < MaxValue; i += 2)
{
var normalizedCoord = vector + new Vector2(vector2.X * i, 0f);
if (i == Value - 1)
MyGuiManager.DrawSpriteBatch(HalfFilledTexture, normalizedCoord, normalizedSize,
ApplyColorMaskModifiers(ColorMask, Enabled, transitionAlpha),
MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP, false, false);
else if (i < Value)
MyGuiManager.DrawSpriteBatch(FilledTexture, normalizedCoord, normalizedSize,
ApplyColorMaskModifiers(ColorMask, Enabled, transitionAlpha),
MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP, false, false);
else
MyGuiManager.DrawSpriteBatch(EmptyTexture, normalizedCoord, normalizedSize,
ApplyColorMaskModifiers(ColorMask, Enabled, transitionAlpha),
MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP, false, false);
}
}
}

View File

@@ -0,0 +1,13 @@
namespace PluginLoader.GUI;
public class ItemView
{
public readonly string[] Labels;
public readonly object[] Values;
public ItemView(string[] labels, object[] values)
{
Labels = labels;
Values = values;
}
}

View File

@@ -0,0 +1,832 @@
using System.Text;
using PluginLoader.Data;
using PluginLoader.Patch;
using PluginLoader.Stats;
using PluginLoader.Stats.Model;
using Sandbox;
using Sandbox.Game.Gui;
using Sandbox.Game.Multiplayer;
using Sandbox.Game.Screens.Helpers;
using Sandbox.Game.World;
using Sandbox.Graphics.GUI;
using VRage;
using VRage.Audio;
using VRage.Game;
using VRage.Input;
using VRage.Utils;
using VRageMath;
using static Sandbox.Graphics.GUI.MyGuiScreenMessageBox;
using Parallel = ParallelTasks.Parallel;
namespace PluginLoader.GUI;
public class MyGuiScreenPluginConfig : MyGuiScreenBase
{
private const float BarWidth = 0.85f;
private const float Spacing = 0.0175f;
private static bool allItemsVisible = true;
public readonly Dictionary<string, bool> AfterRebootEnableFlags = new();
private readonly Dictionary<string, MyGuiControlCheckbox> pluginCheckboxes = new();
private readonly PluginDetailsPanel pluginDetails;
private MyGuiControlButton buttonMore;
private MyGuiControlContextMenu contextMenu;
private bool forceRestart;
private MyGuiControlContextMenu pluginContextMenu;
private MyGuiControlLabel pluginCountLabel;
public PluginStats PluginStats;
private MyGuiControlTable pluginTable;
private string[] tableFilter;
/// <summary>
/// The plugins screen, the constructor itself sets up the menu properties.
/// </summary>
private MyGuiScreenPluginConfig() : base(new Vector2(0.5f, 0.5f), MyGuiConstants.SCREEN_BACKGROUND_COLOR,
new Vector2(1f, 0.97f), false, null, MySandboxGame.Config.UIBkOpacity,
MySandboxGame.Config.UIOpacity)
{
EnabledBackgroundFade = true;
m_closeOnEsc = true;
m_drawEvenWithoutFocus = true;
CanHideOthers = true;
CanBeHidden = true;
CloseButtonEnabled = true;
foreach (var plugin in Main.Instance.List)
AfterRebootEnableFlags[plugin.Id] = plugin.Enabled;
pluginDetails = new(this);
}
private static PluginConfig Config => Main.Instance.Config;
private PluginData SelectedPlugin
{
get => pluginDetails.Plugin;
set => pluginDetails.Plugin = value;
}
private bool RequiresRestart => forceRestart ||
Main.Instance.List.Any(
plugin => plugin.Enabled != AfterRebootEnableFlags[plugin.Id]);
public static void OpenMenu()
{
if (Main.Instance.List.HasError)
MyGuiSandbox.AddScreen(MyGuiSandbox.CreateMessageBox(buttonType: MyMessageBoxButtonsType.OK,
messageText: new(
"An error occurred while downloading the plugin list.\nPlease send your game log to the developers of Plugin Loader."),
messageCaption: MyTexts.Get(
MyCommonTexts.MessageBoxCaptionError),
callback: x =>
MyGuiSandbox.AddScreen(
new MyGuiScreenPluginConfig())));
else
MyGuiSandbox.AddScreen(new MyGuiScreenPluginConfig());
}
public override string GetFriendlyName()
{
return "MyGuiScreenPluginConfig";
}
public override void LoadContent()
{
base.LoadContent();
RecreateControls(true);
PlayerConsent.OnConsentChanged += OnConsentChanged;
}
public override void HandleUnhandledInput(bool receivedFocusInThisUpdate)
{
var input = MyInput.Static;
if (input.IsNewKeyPressed(MyKeys.F5) && input.IsAnyAltKeyPressed() && input.IsAnyCtrlKeyPressed())
Patch_IngameRestart.ShowRestartMenu();
}
public override void UnloadContent()
{
PlayerConsent.OnConsentChanged -= OnConsentChanged;
pluginDetails.OnPluginToggled -= EnablePlugin;
base.UnloadContent();
}
private void OnConsentChanged()
{
DownloadStats();
}
private void DownloadStats()
{
LogFile.WriteLine("Downloading user statistics", false);
Parallel.Start(() => { PluginStats = StatsClient.DownloadStats(); }, OnDownloadedStats);
}
private void OnDownloadedStats()
{
pluginDetails?.LoadPluginData();
}
/// <summary>
/// Initializes the controls of the menu on the left side of the menu.
/// </summary>
public override void RecreateControls(bool constructor)
{
base.RecreateControls(constructor);
var title = AddCaption("Plugins List");
// Sets the origin relative to the center of the caption on the X axis and to the bottom the caption on the y axis.
var origin = title.Position += new Vector2(0f, title.Size.Y / 2);
origin.Y += Spacing;
// Adds a bar right below the caption.
var titleBar = new MyGuiControlSeparatorList();
titleBar.AddHorizontal(new(origin.X - BarWidth / 2, origin.Y), BarWidth);
Controls.Add(titleBar);
origin.Y += Spacing;
// Change the position of this to move the entire middle section of the menu, the menu bars, menu title, and bottom buttons won't move
// Adds a search bar right below the bar on the left side of the menu.
var searchBox = new MyGuiControlSearchBox(new Vector2(origin.X - BarWidth / 2, origin.Y),
originAlign: MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP);
// Changing the search box X size will change the plugin list length.
searchBox.Size = new(0.4f, searchBox.Size.Y);
searchBox.OnTextChanged += SearchBox_TextChanged;
Controls.Add(searchBox);
#region Visibility Button
// Adds a button to show only enabled plugins. Located right of the search bar.
var buttonVisibility = new MyGuiControlButton(
new Vector2(origin.X - BarWidth / 2 + searchBox.Size.X, origin.Y) + new Vector2(0.003f, 0.002f),
MyGuiControlButtonStyleEnum.Rectangular, new Vector2(searchBox.Size.Y * 2.52929769833f),
onButtonClick: OnVisibilityClick, originAlign: MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP,
toolTip: "Show only enabled plugins.", buttonScale: 0.5f);
if (allItemsVisible || Config.Count == 0)
{
allItemsVisible = true;
buttonVisibility.Icon = IconHide;
}
else
{
buttonVisibility.Icon = IconShow;
}
Controls.Add(buttonVisibility);
#endregion
origin.Y += searchBox.Size.Y + Spacing;
#region Plugin List
// Adds the plugin list on the right of the menu below the search bar.
pluginTable = new()
{
Position = new(origin.X - BarWidth / 2, origin.Y),
Size = new(searchBox.Size.X + buttonVisibility.Size.X + 0.001f,
0.6f), // The y value can be bigger than the visible rows count as the visibleRowsCount controls the height.
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP,
ColumnsCount = 3,
VisibleRowsCount = 20
};
pluginTable.SetCustomColumnWidths(new[]
{
0.22f,
0.6f,
0.22f
});
pluginTable.SetColumnName(0, new("Source"));
pluginTable.SetColumnComparison(0, CellTextOrDataComparison);
pluginTable.SetColumnName(1, new("Name"));
pluginTable.SetColumnComparison(1, CellTextComparison);
pluginTable.SetColumnName(2, new("Enable"));
pluginTable.SetColumnComparison(2, CellTextComparison);
// Default sorting
pluginTable.SortByColumn(2, MyGuiControlTable.SortStateEnum.Ascending);
// Selecting list items load their details in OnItemSelected
pluginTable.ItemSelected += OnItemSelected;
Controls.Add(pluginTable);
// Double clicking list items toggles the enable flag
pluginTable.ItemDoubleClicked += OnItemDoubleClicked;
#endregion
origin.Y += Spacing + pluginTable.Size.Y;
// Adds the bar at the bottom between just above the buttons.
var bottomBar = new MyGuiControlSeparatorList();
bottomBar.AddHorizontal(new(origin.X - BarWidth / 2, origin.Y), BarWidth);
Controls.Add(bottomBar);
origin.Y += Spacing;
// Adds buttons at bottom of menu
var buttonRestart = new MyGuiControlButton(origin, MyGuiControlButtonStyleEnum.Default, null, null,
MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
"Restart the game and apply changes.", new("Apply"), 0.8f,
MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_CENTER,
MyGuiControlHighlightType.WHEN_ACTIVE, OnRestartButtonClick);
var buttonClose = new MyGuiControlButton(origin, MyGuiControlButtonStyleEnum.Default, null, null,
MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
"Closes the dialog without saving changes to plugin selection",
new("Cancel"), 0.8f,
MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_CENTER,
MyGuiControlHighlightType.WHEN_ACTIVE, OnCancelButtonClick);
buttonMore = new(origin, MyGuiControlButtonStyleEnum.Tiny, null, null,
MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP, "Advanced", new("..."), 0.8f,
MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_CENTER,
MyGuiControlHighlightType.WHEN_ACTIVE, OnMoreButtonClick);
// FIXME: Use MyLayoutHorizontal instead
AlignRow(origin, 0.05f, buttonRestart, buttonClose);
Controls.Add(buttonRestart);
Controls.Add(buttonClose);
buttonMore.Position = buttonClose.Position + new Vector2(buttonClose.Size.X / 2 + 0.05f, 0);
Controls.Add(buttonMore);
// Adds a place to show the total amount of plugins and to show the total amount of visible plugins.
pluginCountLabel = new(new Vector2(origin.X - BarWidth / 2, buttonRestart.Position.Y),
originAlign: MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP);
Controls.Add(pluginCountLabel);
// Right side panel showing the details of the selected plugin
var rightSideOrigin = buttonVisibility.Position +
new Vector2(Spacing * 1.778f + buttonVisibility.Size.X / 2,
-(buttonVisibility.Size.Y / 2));
pluginDetails.CreateControls(rightSideOrigin);
Controls.Add(pluginDetails);
pluginDetails.OnPluginToggled += EnablePlugin;
// Context menu for the more (...) button
contextMenu = new();
contextMenu.Deactivate();
contextMenu.CreateNewContextMenu();
contextMenu.AddItem(new("Add development folder"), "Open and compile a folder for development",
userData: nameof(OnLoadFolder));
contextMenu.AddItem(new("Save profile"), "Saved the current plugin selection", userData: nameof(OnSaveProfile));
contextMenu.AddItem(new("Load profile"), "Loads a saved plugin selection", userData: nameof(OnLoadProfile));
contextMenu.AddItem(new("------------"));
contextMenu.AddItem(
new(PlayerConsent.ConsentGiven ? "Revoke consent" : "Give consent"),
PlayerConsent.ConsentGiven
? "Revoke consent to data handling, clear my votes"
: "Give consent to data handling, allow me to vote",
userData: nameof(OnConsent));
contextMenu.Enabled = true;
contextMenu.ItemClicked += OnContextMenuItemClicked;
contextMenu.OnDeactivated += OnContextMenuDeactivated;
// contextMenu.SetMaxSize(new Vector2(0.2f, 0.7f));
Controls.Add(contextMenu);
// Context menu for the plugin list
pluginContextMenu = new();
pluginContextMenu.Deactivate();
pluginContextMenu.CreateNewContextMenu();
pluginContextMenu.ItemClicked += OnPluginContextMenuItemClicked;
pluginContextMenu.OnDeactivated += OnContextMenuDeactivated;
Controls.Add(pluginContextMenu);
// Refreshes the table to show plugins on plugin list
RefreshTable();
DownloadStats();
}
public void RequireRestart()
{
forceRestart = true;
}
private void OnLoadFolder()
{
LocalFolderPlugin.CreateNew(plugin =>
{
Config.PluginFolders[plugin.Id] = plugin.FolderSettings;
CreatePlugin(plugin);
});
}
public void CreatePlugin(PluginData data)
{
Main.Instance.List.Add(data);
AfterRebootEnableFlags[data.Id] = true;
Config.SetEnabled(data.Id, true);
forceRestart = true;
RefreshTable(tableFilter);
}
public void RemovePlugin(PluginData data)
{
Main.Instance.List.Remove(data.Id);
AfterRebootEnableFlags.Remove(data.Id);
Config.SetEnabled(data.Id, false);
forceRestart = true;
RefreshTable(tableFilter);
}
public void RefreshSidePanel()
{
pluginDetails?.LoadPluginData();
}
/// <summary>
/// Event that triggers when the visibility button is clicked. This method shows all plugins or only enabled plugins.
/// </summary>
/// <param name="btn">The button to assign this event to.</param>
private void OnVisibilityClick(MyGuiControlButton btn)
{
if (allItemsVisible)
{
allItemsVisible = false;
btn.Icon = IconShow;
}
else
{
allItemsVisible = true;
btn.Icon = IconHide;
}
RefreshTable(tableFilter);
}
private static int CellTextOrDataComparison(MyGuiControlTable.Cell x, MyGuiControlTable.Cell y)
{
var result = TextComparison(x.Text, y.Text);
if (result != 0) return result;
return TextComparison((StringBuilder)x.UserData, (StringBuilder)y.UserData);
}
private static int CellTextComparison(MyGuiControlTable.Cell x, MyGuiControlTable.Cell y)
{
return TextComparison(x.Text, y.Text);
}
private static int TextComparison(StringBuilder x, StringBuilder y)
{
if (x == null)
{
if (y == null)
return 0;
return 1;
}
if (y == null)
return -1;
return x.CompareTo(y);
}
/// <summary>
/// Clears the table and adds the list of plugins and their information.
/// </summary>
/// <param name="filter">Text filter</param>
private void RefreshTable(string[] filter = null)
{
pluginTable.Clear();
pluginTable.Controls.Clear();
pluginCheckboxes.Clear();
var list = Main.Instance.List;
var noFilter = filter == null || filter.Length == 0;
foreach (var plugin in list)
{
var enabled = AfterRebootEnableFlags[plugin.Id];
if (noFilter && (plugin.Hidden || !allItemsVisible) && !enabled)
continue;
if (!noFilter && !FilterName(plugin.FriendlyName, filter))
continue;
var row = new MyGuiControlTable.Row(plugin);
pluginTable.Add(row);
var name = new StringBuilder(plugin.FriendlyName);
row.AddCell(new(plugin.Source, name));
var tip = plugin.FriendlyName;
if (!string.IsNullOrWhiteSpace(plugin.Tooltip))
tip += "\n" + plugin.Tooltip;
row.AddCell(new(plugin.FriendlyName, toolTip: tip));
var text = new StringBuilder(FormatCheckboxSortKey(plugin, enabled));
var enabledCell = new MyGuiControlTable.Cell(text, name);
var enabledCheckbox = new MyGuiControlCheckbox(isChecked: enabled)
{
UserData = plugin,
Visible = true
};
enabledCheckbox.IsCheckedChanged += OnPluginCheckboxChanged;
enabledCell.Control = enabledCheckbox;
pluginTable.Controls.Add(enabledCheckbox);
pluginCheckboxes[plugin.Id] = enabledCheckbox;
row.AddCell(enabledCell);
}
pluginCountLabel.Text = pluginTable.RowsCount + "/" + list.Count + " visible";
pluginTable.Sort(false);
pluginTable.SelectedRowIndex = null;
tableFilter = filter;
pluginTable.SelectedRowIndex = 0;
var args = new MyGuiControlTable.EventArgs { RowIndex = 0 };
OnItemSelected(pluginTable, args);
}
private static string FormatCheckboxSortKey(PluginData plugin, bool enabled)
{
// Uses a prefix of + and - to list plugins to enable to the top
return enabled ? $"+{plugin.FriendlyName}|{plugin.Source}" : $"-{plugin.FriendlyName}|{plugin.Source}";
}
/// <summary>
/// Event that triggers when the text in the searchbox is changed.
/// </summary>
/// <param name="txt">The text that was entered into the searchbox.</param>
private void SearchBox_TextChanged(string txt)
{
var args = txt.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
RefreshTable(args);
}
private static bool FilterName(string name, IEnumerable<string> filter)
{
return filter.All(s => name.Contains(s, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Sets text on right side of screen.
/// </summary>
/// <param name="table">Table to get the plugin data.</param>
/// <param name="args">Event arguments.</param>
private void OnItemSelected(MyGuiControlTable table, MyGuiControlTable.EventArgs args)
{
if (!TryGetPluginByRowIndex(args.RowIndex, out var plugin))
return;
if (args.MouseButton == MyMouseButtonsEnum.Right && plugin.OpenContextMenu(pluginContextMenu))
{
pluginContextMenu.ItemList_UseSimpleItemListMouseOverCheck = true;
pluginContextMenu.Activate();
}
contextMenu.Deactivate();
SelectedPlugin = plugin;
}
private void OnItemDoubleClicked(MyGuiControlTable table, MyGuiControlTable.EventArgs args)
{
if (!TryGetPluginByRowIndex(args.RowIndex, out var data))
return;
EnablePlugin(data, !AfterRebootEnableFlags[data.Id]);
}
private bool TryGetPluginByRowIndex(int rowIndex, out PluginData plugin)
{
if (rowIndex < 0 || rowIndex >= pluginTable.RowsCount)
{
plugin = null;
return false;
}
var row = pluginTable.GetRow(rowIndex);
plugin = row.UserData as PluginData;
return plugin != null;
}
private void AlignRow(Vector2 origin, float spacing, params MyGuiControlBase[] elements)
{
if (elements.Length == 0)
return;
float totalWidth = 0;
for (var i = 0; i < elements.Length; i++)
{
var btn = elements[i];
totalWidth += btn.Size.X;
if (i < elements.Length - 1)
totalWidth += spacing;
}
var originX = origin.X - totalWidth / 2;
foreach (var btn in elements)
{
var halfWidth = btn.Size.X / 2;
originX += halfWidth;
btn.Position = new(originX, origin.Y);
originX += spacing + halfWidth;
}
}
private void OnPluginCheckboxChanged(MyGuiControlCheckbox checkbox)
{
var plugin = (PluginData)checkbox.UserData;
EnablePlugin(plugin, checkbox.IsChecked);
if (ReferenceEquals(plugin, SelectedPlugin))
pluginDetails.LoadPluginData();
}
public void EnablePlugin(PluginData plugin, bool enable)
{
if (enable == AfterRebootEnableFlags[plugin.Id])
return;
AfterRebootEnableFlags[plugin.Id] = enable;
SetPluginCheckbox(plugin, enable);
if (enable)
{
DisableOtherPluginsInSameGroup(plugin);
EnableDependencies(plugin);
}
}
private void SetPluginCheckbox(PluginData plugin, bool enable)
{
if (!pluginCheckboxes.TryGetValue(plugin.Id, out var checkbox))
return; // The checkbox might not exist if the target plugin is a dependency not currently in the table
checkbox.IsChecked = enable;
var row = pluginTable.Find(x => ReferenceEquals(x.UserData as PluginData, plugin));
row?.GetCell(2).Text.Clear().Append(FormatCheckboxSortKey(plugin, enable));
}
private void DisableOtherPluginsInSameGroup(PluginData plugin)
{
foreach (var other in plugin.Group)
if (!ReferenceEquals(other, plugin))
EnablePlugin(other, false);
}
private void EnableDependencies(PluginData plugin)
{
if (plugin is not ModPlugin mod || mod.Dependencies == null)
return;
foreach (PluginData other in mod.Dependencies)
if (!ReferenceEquals(other, plugin))
EnablePlugin(other, true);
}
private void OnCancelButtonClick(MyGuiControlButton btn)
{
CloseScreen();
}
private void OnMoreButtonClick(MyGuiControlButton _)
{
contextMenu.ItemList_UseSimpleItemListMouseOverCheck = true;
contextMenu.Enabled = false;
contextMenu.Activate(false);
contextMenu.OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP;
contextMenu.Position = buttonMore.Position + buttonMore.Size * new Vector2(-1.3f, -1.9f);
FocusContextMenuList();
}
private void FocusContextMenuList()
{
var guiControlsOwner = (IMyGuiControlsOwner)contextMenu;
while (guiControlsOwner.Owner != null)
{
guiControlsOwner = guiControlsOwner.Owner;
if (guiControlsOwner is not MyGuiScreenBase myGuiScreenBase)
continue;
myGuiScreenBase.FocusedControl = contextMenu.GetInnerList();
break;
}
}
private void OnContextMenuDeactivated()
{
contextMenu.Enabled = true;
}
private void OnContextMenuItemClicked(MyGuiControlContextMenu _, MyGuiControlContextMenu.EventArgs args)
{
contextMenu.Deactivate();
switch ((string)args.UserData)
{
case nameof(OnLoadFolder):
OnLoadFolder();
break;
case nameof(OnSaveProfile):
OnSaveProfile();
break;
case nameof(OnLoadProfile):
OnLoadProfile();
break;
case nameof(OnConsent):
OnConsent();
break;
}
}
private void OnPluginContextMenuItemClicked(MyGuiControlContextMenu menu, MyGuiControlContextMenu.EventArgs args)
{
SelectedPlugin?.ContextMenuClicked(this, args);
}
private void OnSaveProfile()
{
var timestamp = DateTime.Now.ToString("O").Substring(0, 19).Replace('T', ' ');
MyGuiSandbox.AddScreen(new NameDialog(OnProfileNameProvided, "Save profile", timestamp));
}
private void OnProfileNameProvided(string name)
{
var afterRebootEnablePluginIds = AfterRebootEnableFlags
.Where(p => p.Value)
.Select(p => p.Key);
var profile = new Profile(name, afterRebootEnablePluginIds.ToArray());
Config.ProfileMap[profile.Key] = profile;
Config.Save();
}
private void OnLoadProfile()
{
MyGuiSandbox.AddScreen(new ProfilesDialog("Load profile", OnProfileLoaded));
}
private void OnProfileLoaded(Profile profile)
{
var pluginsEnabledInProfile = profile.Plugins.ToHashSet();
foreach (var plugin in Main.Instance.List)
EnablePlugin(plugin, pluginsEnabledInProfile.Contains(plugin.Id));
pluginTable.SortByColumn(2, MyGuiControlTable.SortStateEnum.Ascending);
}
private void OnConsent()
{
PlayerConsent.ShowDialog();
}
private void OnRestartButtonClick(MyGuiControlButton btn)
{
if (!RequiresRestart)
{
CloseScreen();
return;
}
MyGuiSandbox.AddScreen(MyGuiSandbox.CreateMessageBox(MyMessageBoxStyleEnum.Info,
MyMessageBoxButtonsType.YES_NO_CANCEL,
new(
"A restart is required to apply changes. Would you like to restart the game now?"),
new("Apply Changes?"), callback: AskRestartResult));
}
private void Save()
{
if (!RequiresRestart)
return;
foreach (var plugin in Main.Instance.List)
Config.SetEnabled(plugin.Id, AfterRebootEnableFlags[plugin.Id]);
Config.Save();
}
#region Icons
// Source: MyTerminalControlPanel
private static readonly MyGuiHighlightTexture IconHide = new()
{
Normal = "Textures\\GUI\\Controls\\button_hide.dds",
Highlight = "Textures\\GUI\\Controls\\button_hide.dds",
Focus = "Textures\\GUI\\Controls\\button_hide_focus.dds",
SizePx = new(40f, 40f)
};
// Source: MyTerminalControlPanel
private static readonly MyGuiHighlightTexture IconShow = new()
{
Normal = "Textures\\GUI\\Controls\\button_unhide.dds",
Highlight = "Textures\\GUI\\Controls\\button_unhide.dds",
Focus = "Textures\\GUI\\Controls\\button_unhide_focus.dds",
SizePx = new(40f, 40f)
};
#endregion
#region Restart
private void AskRestartResult(ResultEnum result)
{
if (result == ResultEnum.YES)
{
Save();
if (MyGuiScreenGamePlay.Static != null)
{
ShowSaveMenu(delegate { LoaderTools.UnloadAndRestart(); });
return;
}
LoaderTools.UnloadAndRestart();
}
else if (result == ResultEnum.NO)
{
Save();
CloseScreen();
}
}
/// <summary>
/// From WesternGamer/InGameWorldLoading
/// </summary>
/// <param name="afterMenu">Action after code is executed.</param>
private static void ShowSaveMenu(Action afterMenu)
{
// Sync.IsServer is backwards
if (!Sync.IsServer)
{
afterMenu();
return;
}
var message = "";
var isCampaign = false;
var buttonsType = MyMessageBoxButtonsType.YES_NO_CANCEL;
// Sync.IsServer is backwards
if (Sync.IsServer && !MySession.Static.Settings.EnableSaving)
{
message +=
"Are you sure that you want to restart the game? All progress from the last checkpoint will be lost.";
isCampaign = true;
buttonsType = MyMessageBoxButtonsType.YES_NO;
}
else
{
message += "Save changes before restarting game?";
}
var saveMenu = MyGuiSandbox.CreateMessageBox(buttonType: buttonsType, messageText: new(message),
messageCaption: MyTexts.Get(
MyCommonTexts.MessageBoxCaptionPleaseConfirm),
callback: ShowSaveMenuCallback,
cancelButtonText: MyStringId.GetOrCompute("Don't Restart"));
saveMenu.InstantClose = false;
MyGuiSandbox.AddScreen(saveMenu);
void ShowSaveMenuCallback(ResultEnum callbackReturn)
{
if (isCampaign)
{
if (callbackReturn == ResultEnum.YES)
afterMenu();
return;
}
switch (callbackReturn)
{
case ResultEnum.YES:
MyAsyncSaving.Start(delegate
{
MySandboxGame.Static.OnScreenshotTaken +=
UnloadAndExitAfterScreenshotWasTaken;
});
break;
case ResultEnum.NO:
MyAudio.Static.Mute = true;
MyAudio.Static.StopMusic();
afterMenu();
break;
}
}
void UnloadAndExitAfterScreenshotWasTaken(object sender, EventArgs e)
{
MySandboxGame.Static.OnScreenshotTaken -= UnloadAndExitAfterScreenshotWasTaken;
afterMenu();
}
}
#endregion
}

View File

@@ -0,0 +1,123 @@
using Sandbox;
using Sandbox.Game.Gui;
using Sandbox.Game.Localization;
using Sandbox.Graphics.GUI;
using VRage;
using VRage.Utils;
using VRageMath;
using Color = VRageMath.Color;
// ReSharper disable VirtualMemberCallInConstructor
#pragma warning disable 618
namespace PluginLoader.GUI;
internal class NameDialog : MyGuiScreenDebugBase
{
private readonly string caption;
private readonly string defaultName;
private readonly int maxLength;
private readonly Action<string> onOk;
private MyGuiControlButton cancelButton;
private MyGuiControlTextbox nameBox;
private MyGuiControlButton okButton;
public NameDialog(
Action<string> onOk,
string caption = "Name",
string defaultName = "",
int maxLength = 40)
: base(new(0.5f, 0.5f), new Vector2(0.5f, 0.28f),
MyGuiConstants.SCREEN_BACKGROUND_COLOR * MySandboxGame.Config.UIBkOpacity, true)
{
this.onOk = onOk;
this.caption = caption;
this.defaultName = defaultName;
this.maxLength = maxLength;
RecreateControls(true);
CanBeHidden = true;
CanHideOthers = true;
CloseButtonEnabled = true;
m_onEnterCallback = ReturnOk;
}
private Vector2 DialogSize => m_size ?? Vector2.One;
public override void RecreateControls(bool constructor)
{
base.RecreateControls(constructor);
AddCaption(caption, Color.White.ToVector4(), new Vector2(0.0f, 0.003f));
var controlSeparatorList1 = new MyGuiControlSeparatorList();
controlSeparatorList1.AddHorizontal(new(-0.39f * DialogSize.X, -0.5f * DialogSize.Y + 0.075f),
DialogSize.X * 0.78f);
Controls.Add(controlSeparatorList1);
var controlSeparatorList2 = new MyGuiControlSeparatorList();
controlSeparatorList2.AddHorizontal(new(-0.39f * DialogSize.X, +0.5f * DialogSize.Y - 0.123f),
DialogSize.X * 0.78f);
Controls.Add(controlSeparatorList2);
nameBox = new(new Vector2(0.0f, -0.027f), maxLength: maxLength)
{
Text = defaultName,
Size = new(0.385f, 1f)
};
nameBox.SelectAll();
Controls.Add(nameBox);
okButton = new(originAlign: MyGuiDrawAlignEnum.HORISONTAL_RIGHT_AND_VERTICAL_CENTER,
text: MyTexts.Get(MyCommonTexts.Ok), onButtonClick: OnOk);
cancelButton = new(originAlign: MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_CENTER,
text: MyTexts.Get(MyCommonTexts.Cancel), onButtonClick: OnCancel);
var okPosition = new Vector2(0.001f, 0.5f * DialogSize.Y - 0.071f);
var halfDistance = new Vector2(0.018f, 0.0f);
okButton.Position = okPosition - halfDistance;
cancelButton.Position = okPosition + halfDistance;
okButton.SetToolTip(MyTexts.GetString(MySpaceTexts.ToolTipNewsletter_Ok));
cancelButton.SetToolTip(MyTexts.GetString(MySpaceTexts.ToolTipOptionsSpace_Cancel));
Controls.Add(okButton);
Controls.Add(cancelButton);
}
private void CallResultCallback(string text)
{
if (text == null)
return;
onOk(text);
}
private void ReturnOk()
{
if (nameBox.GetTextLength() <= 0)
return;
CallResultCallback(nameBox.Text);
CloseScreen();
}
private void OnOk(MyGuiControlButton button)
{
ReturnOk();
}
private void OnCancel(MyGuiControlButton button)
{
CloseScreen();
}
public override string GetFriendlyName()
{
return "NameDialog";
}
}

View File

@@ -0,0 +1,90 @@
using PluginLoader.Stats;
using Sandbox.Graphics.GUI;
using VRageMath;
namespace PluginLoader.GUI;
public static class PlayerConsent
{
public static bool ConsentRequested => !string.IsNullOrEmpty(Main.Instance.Config.DataHandlingConsentDate);
public static bool ConsentGiven => Main.Instance.Config.DataHandlingConsent;
public static event Action OnConsentChanged;
public static void ShowDialog(Action continuation = null)
{
MyGuiSandbox.AddScreen(
ConfirmationDialog.CreateMessageBox(buttonType: MyMessageBoxButtonsType.YES_NO_CANCEL,
messageText: new(
" Would you like to rate plugins and inform developers?\r\n" +
"\r\n" +
"\r\n" +
"YES: Plugin Loader will send the list of enabled plugins to our server\r\n" +
" each time the game starts. Your Steam ID is sent only in hashed form,\r\n" +
" which makes it hard to identify you. Plugin usage statistics is kept\r\n" +
" for up to 90 days. Votes on plugins are preserved indefinitely.\r\n" +
" Server log files and database backups may be kept up to 90 days.\r\n" +
" Location of data storage: European Union\r\n" +
"\r\n" +
"\r\n" +
"NO: None of your data will be sent to nor stored on our statistics server.\r\n" +
" Plugin Loader will still connect to download the statistics shown.\r\n"),
size: new Vector2(0.6f, 0.6f),
messageCaption: new("Consent"),
callback: result => GetConfirmation(result, continuation)));
}
private static void GetConfirmation(MyGuiScreenMessageBox.ResultEnum result, Action continuation)
{
if (result == MyGuiScreenMessageBox.ResultEnum.CANCEL)
return;
var consent = result == MyGuiScreenMessageBox.ResultEnum.YES;
var consentWithdrawn = ConsentRequested && ConsentGiven && !consent;
if (consentWithdrawn)
{
MyGuiSandbox.AddScreen(MyGuiSandbox.CreateMessageBox(MyMessageBoxStyleEnum.Info,
MyMessageBoxButtonsType.YES_NO_CANCEL,
new(
"Are you sure to withdraw your consent to data handling?\r\n\r\nDoing so would irrecoverably remove all your votes\r\nand usage data from our statistics server."),
new("Confirm consent withdrawal"),
callback: res =>
StoreConsent(res, false, continuation)));
return;
}
StoreConsent(MyGuiScreenMessageBox.ResultEnum.YES, consent, continuation);
}
private static void StoreConsent(MyGuiScreenMessageBox.ResultEnum confirmationResult, bool consent,
Action continuation)
{
if (confirmationResult != MyGuiScreenMessageBox.ResultEnum.YES)
return;
if (ConsentRequested && consent == ConsentGiven)
{
continuation?.Invoke();
return;
}
if (!StatsClient.Consent(consent))
{
LogFile.WriteLine("Failed to register player consent on statistics server");
return;
}
var config = Main.Instance.Config;
config.DataHandlingConsentDate = Tools.Tools.FormatDateIso8601(DateTime.Today);
config.DataHandlingConsent = consent;
config.Save();
if (consent)
StatsClient.Track(Main.Instance.TrackablePluginIds);
OnConsentChanged?.Invoke();
continuation?.Invoke();
}
}

View File

@@ -0,0 +1,439 @@
using PluginLoader.Data;
using PluginLoader.GUI.GuiControls;
using PluginLoader.Stats;
using PluginLoader.Stats.Model;
using Sandbox.Graphics.GUI;
using VRage.Game;
using VRage.Utils;
using VRageMath;
namespace PluginLoader.GUI;
public class PluginDetailsPanel : MyGuiControlParent
{
private readonly PluginStat dummyStat = new();
private readonly MyGuiScreenPluginConfig pluginsDialog;
private MyGuiControlLabel authorLabel;
private MyGuiControlLabel authorText;
private MyGuiControlButton configButton;
private MyGuiControlCompositePanel descriptionPanel;
private MyGuiControlMultilineText descriptionText;
private MyGuiControlButton downvoteButton;
private MyGuiControlLabel downvoteCountText;
private MyGuiControlImage downvoteIcon;
private MyGuiControlCheckbox enableCheckbox;
private MyGuiControlLabel enableLabel;
private MyGuiControlButton infoButton;
private PluginInstance instance;
// Layout management
private MyLayoutTable layoutTable;
// Plugin currently loaded into the panel or null if none are loaded
private PluginData plugin;
// Panel controls
private MyGuiControlLabel pluginNameLabel;
private MyGuiControlLabel pluginNameText;
private RatingControl ratingControl;
private MyGuiControlLabel ratingLabel;
private MyGuiControlLabel statusLabel;
private MyGuiControlLabel statusText;
private MyGuiControlButton upvoteButton;
private MyGuiControlLabel upvoteCountText;
private MyGuiControlImage upvoteIcon;
private MyGuiControlLabel usageLabel;
private MyGuiControlLabel usageText;
private MyGuiControlLabel versionLabel;
private MyGuiControlLabel versionText;
public PluginDetailsPanel(MyGuiScreenPluginConfig dialog)
{
pluginsDialog = dialog;
}
public PluginData Plugin
{
get => plugin;
set
{
if (ReferenceEquals(value, Plugin))
return;
plugin = value;
if (plugin == null)
{
DisableControls();
ClearPluginData();
return;
}
if (Main.Instance.TryGetPluginInstance(plugin.Id, out var instance))
this.instance = instance;
else
this.instance = null;
EnableControls();
LoadPluginData();
}
}
private PluginStat PluginStat => pluginsDialog.PluginStats?.Stats.GetValueOrDefault(plugin.Id) ?? dummyStat;
public event Action<PluginData, bool> OnPluginToggled;
private void DisableControls()
{
foreach (var control in Controls)
control.Enabled = false;
}
private void EnableControls()
{
foreach (var control in Controls)
control.Enabled = true;
}
private void ClearPluginData()
{
pluginNameText.Text = "";
authorText.Text = "";
versionText.Text = "";
statusText.Text = "";
usageText.Text = "";
ratingControl.Value = 0;
upvoteButton.Checked = false;
downvoteButton.Checked = false;
descriptionText.Text.Clear();
enableCheckbox.IsChecked = false;
}
public void LoadPluginData()
{
if (plugin == null)
return;
var stat = PluginStat;
var vote = stat.Vote;
var nonLocal = !plugin.IsLocal;
var canVote = (plugin.Enabled || stat.Tried) && nonLocal;
var showVotes = canVote || nonLocal;
pluginNameText.Text = plugin.FriendlyName ?? "N/A";
authorText.Text = plugin.Author ?? (plugin.IsLocal ? "Local" : "N/A");
versionText.Text = plugin.Version?.ToString() ?? "N/A";
statusLabel.Visible = nonLocal;
statusText.Visible = nonLocal;
statusText.Text = plugin.Status == PluginStatus.None
? plugin.Enabled ? "Up to date" : "N/A"
: plugin.StatusString;
usageLabel.Visible = nonLocal;
usageText.Visible = nonLocal;
usageText.Text = stat.Players.ToString();
ratingLabel.Visible = showVotes;
upvoteIcon.Visible = showVotes;
upvoteButton.Visible = canVote;
upvoteButton.Checked = vote > 0;
upvoteCountText.Visible = showVotes;
upvoteCountText.Text = $"{stat.Upvotes}";
downvoteIcon.Visible = showVotes;
downvoteButton.Visible = canVote;
downvoteButton.Checked = vote < 0;
downvoteCountText.Visible = showVotes;
downvoteCountText.Text = $"{stat.Downvotes}";
ratingControl.Value = stat.Rating;
plugin.GetDescriptionText(descriptionText);
descriptionPanel.Visible = descriptionText.Visible;
enableCheckbox.IsChecked = pluginsDialog.AfterRebootEnableFlags[plugin.Id];
configButton.Enabled = instance != null && instance.HasConfigDialog;
configButton.Visible = instance != null;
}
public virtual void CreateControls(Vector2 rightSideOrigin)
{
// Plugin name
pluginNameLabel = new()
{
Text = "Name",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
pluginNameText = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
// Author
authorLabel = new()
{
Text = "Author",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
authorText = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
// Version
versionLabel = new()
{
Text = "Version",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
versionText = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
// Status
statusLabel = new()
{
Text = "Status",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
statusText = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
// Usage
usageLabel = new()
{
Text = "Usage",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
usageText = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
// Rating
ratingLabel = new()
{
Text = "Rating",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
upvoteButton = new(null, MyGuiControlButtonStyleEnum.Rectangular, onButtonClick: OnRateUpClicked,
size: new Vector2(0.03f))
{
CanHaveFocus = false
};
upvoteIcon = CreateRateIcon(upvoteButton, "Textures\\GUI\\Icons\\Blueprints\\like_test.png");
upvoteIcon.CanHaveFocus = false;
upvoteCountText = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
downvoteButton = new(null, MyGuiControlButtonStyleEnum.Rectangular, onButtonClick: OnRateDownClicked,
size: new Vector2(0.03f))
{
CanHaveFocus = false
};
downvoteIcon = CreateRateIcon(downvoteButton, "Textures\\GUI\\Icons\\Blueprints\\dislike_test.png");
downvoteIcon.CanHaveFocus = false;
downvoteCountText = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
ratingControl = new()
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
Visible = false // FIXME: Make the rating (stars) visible later! Its positioning should already be good.
};
// Plugin description
descriptionText = new()
{
Name = "DescriptionText",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
TextAlign = MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP,
TextBoxAlign = MyGuiDrawAlignEnum.HORISONTAL_LEFT_AND_VERTICAL_TOP
};
descriptionText.OnLinkClicked += (x, url) => MyGuiSandbox.OpenUrl(url, UrlOpenMode.SteamOrExternalWithConfirm);
descriptionPanel = new()
{
BackgroundTexture = MyGuiConstants.TEXTURE_RECTANGLE_DARK_BORDER
};
// Enable checkbox
enableLabel = new()
{
Text = "Enabled",
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP
};
enableCheckbox = new(toolTip: "Enables loading the plugin when SE is started.")
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
Enabled = false
};
enableCheckbox.IsCheckedChanged += TogglePlugin;
// Info button
infoButton = new(onButtonClick: _ => Plugin?.Show())
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
Text = "Plugin Info"
};
// Plugin config button
configButton = new(onButtonClick: _ => instance?.OpenConfig())
{
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
Text = "Plugin Config"
};
LayoutControls(rightSideOrigin);
}
private void LayoutControls(Vector2 rightSideOrigin)
{
layoutTable = new(this, rightSideOrigin, new(1f, 1f));
layoutTable.SetColumnWidths(168f, 468f);
layoutTable.SetRowHeights(60f, 60f, 60f, 60f, 60f, 60f, 420f, 60f, 60f);
var row = 0;
layoutTable.Add(pluginNameLabel, MyAlignH.Left, MyAlignV.Center, row, 0);
layoutTable.Add(pluginNameText, MyAlignH.Left, MyAlignV.Center, row, 1);
row++;
layoutTable.Add(authorLabel, MyAlignH.Left, MyAlignV.Center, row, 0);
layoutTable.Add(authorText, MyAlignH.Left, MyAlignV.Center, row, 1);
row++;
layoutTable.Add(versionLabel, MyAlignH.Left, MyAlignV.Center, row, 0);
layoutTable.Add(versionText, MyAlignH.Left, MyAlignV.Center, row, 1);
row++;
layoutTable.Add(statusLabel, MyAlignH.Left, MyAlignV.Center, row, 0);
layoutTable.Add(statusText, MyAlignH.Left, MyAlignV.Center, row, 1);
row++;
layoutTable.Add(usageLabel, MyAlignH.Left, MyAlignV.Center, row, 0);
layoutTable.Add(usageText, MyAlignH.Left, MyAlignV.Center, row, 1);
row++;
layoutTable.Add(ratingLabel, MyAlignH.Left, MyAlignV.Center, row, 0);
layoutTable.Add(upvoteCountText, MyAlignH.Left, MyAlignV.Center, row, 1);
layoutTable.Add(upvoteButton, MyAlignH.Left, MyAlignV.Center, row, 1);
layoutTable.Add(upvoteIcon, MyAlignH.Left, MyAlignV.Center, row, 1);
layoutTable.Add(downvoteCountText, MyAlignH.Left, MyAlignV.Center, row, 1);
layoutTable.Add(downvoteButton, MyAlignH.Left, MyAlignV.Center, row, 1);
layoutTable.Add(downvoteIcon, MyAlignH.Left, MyAlignV.Center, row, 1);
layoutTable.Add(ratingControl, MyAlignH.Left, MyAlignV.Center, row, 1);
const float counterWidth = 0.05f;
const float spacing = 0.005f;
var buttonWidth = upvoteButton.Size.X;
var voteWidth = buttonWidth + spacing + counterWidth + 3 * spacing;
var buttonToIconOffset = new Vector2(0.004f, -0.001f);
upvoteIcon.Position = upvoteButton.Position + buttonToIconOffset;
upvoteCountText.Position = upvoteButton.Position + new Vector2(buttonWidth + spacing, 0f);
downvoteButton.Position = upvoteButton.Position + new Vector2(voteWidth, 0f);
downvoteIcon.Position = downvoteButton.Position + buttonToIconOffset;
downvoteCountText.Position = downvoteButton.Position + new Vector2(buttonWidth + spacing, 0f);
ratingControl.Position = downvoteButton.Position + new Vector2(voteWidth, 0f);
row++;
layoutTable.AddWithSize(descriptionPanel, MyAlignH.Center, MyAlignV.Top, row, 0, 1, 2);
layoutTable.AddWithSize(descriptionText, MyAlignH.Center, MyAlignV.Center, row, 0, 1, 2);
row++;
layoutTable.Add(enableLabel, MyAlignH.Left, MyAlignV.Center, row, 0);
layoutTable.Add(enableCheckbox, MyAlignH.Left, MyAlignV.Center, row, 1);
row++;
const float infoConfigSpacing = 0.015f;
layoutTable.AddWithSize(infoButton, MyAlignH.Right, MyAlignV.Center, row, 0, 1, 2);
layoutTable.AddWithSize(configButton, MyAlignH.Right, MyAlignV.Center, row, 0, 1, 2);
configButton.Position += new Vector2(0f, infoConfigSpacing);
infoButton.Position = configButton.Position + new Vector2(-configButton.Size.X - infoConfigSpacing, 0);
// row++;
var border = 0.002f * Vector2.One;
descriptionPanel.Position -= border;
descriptionPanel.Size += 2 * border;
DisableControls();
}
private void TogglePlugin(MyGuiControlCheckbox obj)
{
if (plugin == null)
return;
OnPluginToggled?.Invoke(plugin, enableCheckbox.IsChecked);
}
private void OnRateUpClicked(MyGuiControlButton button)
{
Vote(1);
}
private void OnRateDownClicked(MyGuiControlButton button)
{
Vote(-1);
}
private void Vote(int vote)
{
if (PlayerConsent.ConsentGiven)
StoreVote(vote);
else
PlayerConsent.ShowDialog(() => StoreVote(vote));
}
private void StoreVote(int vote)
{
if (!PlayerConsent.ConsentGiven || pluginsDialog.PluginStats == null)
return;
var originalStat = PluginStat;
if (originalStat.Vote == vote)
vote = 0;
var updatedStat = StatsClient.Vote(plugin.Id, vote);
if (updatedStat == null)
return;
pluginsDialog.PluginStats.Stats[plugin.Id] = updatedStat;
LoadPluginData();
}
// From Sandbox.Game.Screens.MyGuiScreenNewWorkshopGame
#region Vote buttons
private MyGuiControlImage CreateRateIcon(MyGuiControlButton button, string texture)
{
var myGuiControlImage = new MyGuiControlImage(null, null, null, null, new[] { texture });
AdjustButtonForIcon(button, myGuiControlImage);
myGuiControlImage.Size = button.Size * 0.6f;
return myGuiControlImage;
}
private void AdjustButtonForIcon(MyGuiControlButton button, MyGuiControlImage icon)
{
button.Size = new(button.Size.X, button.Size.X * 4f / 3f);
button.HighlightChanged += delegate(MyGuiControlBase control)
{
icon.ColorMask = control.HasHighlight ? MyGuiConstants.HIGHLIGHT_TEXT_COLOR : Vector4.One;
};
}
#endregion
}

View File

@@ -0,0 +1,96 @@
using PluginLoader.Data;
namespace PluginLoader.GUI;
public class ProfilesDialog : TableDialogBase
{
private readonly Action<Profile> onProfileLoaded;
public ProfilesDialog(string caption, Action<Profile> onProfileLoaded) : base(caption)
{
this.onProfileLoaded = onProfileLoaded;
}
private static PluginConfig Config => Main.Instance.Config;
private static Dictionary<string, Profile> ProfileMap => Config.ProfileMap;
private static PluginList PluginList => Main.Instance.List;
protected override string ItemName => "profile";
protected override string[] ColumnHeaders => new[] { "Name", "Enabled plugins and mods" };
protected override float[] ColumnWidths => new[] { 0.55f, 0.43f };
protected override object[] ExampleValues => new object[] { null, 0 };
protected override IEnumerable<string> IterItemKeys()
{
return ProfileMap.Keys.ToArray();
}
protected override ItemView GetItemView(string key)
{
if (!ProfileMap.TryGetValue(key, out var profile))
return null;
var locals = 0;
var plugins = 0;
var mods = 0;
foreach (var id in profile.Plugins)
{
if (!PluginList.TryGetPlugin(id, out var plugin))
continue;
switch (plugin)
{
case ModPlugin:
mods++;
break;
case LocalPlugin:
locals++;
break;
default:
plugins++;
break;
}
}
var infoItems = new List<string>();
if (locals > 0)
infoItems.Add(locals > 1 ? $"{locals} local plugins" : "1 local plugin");
if (plugins > 0)
infoItems.Add(plugins > 1 ? $"{plugins} plugins" : "1 plugin");
if (mods > 0)
infoItems.Add(mods > 1 ? $"{mods} mods" : "1 mod");
var info = string.Join(", ", infoItems);
var labels = new[] { profile.Name, info };
var total = locals + plugins + mods;
var values = new object[] { null, total };
return new(labels, values);
}
protected override void OnLoad(string key)
{
if (!ProfileMap.TryGetValue(key, out var profile))
return;
onProfileLoaded(profile);
}
protected override void OnRenamed(string key, string name)
{
if (!ProfileMap.TryGetValue(key, out var profile))
return;
profile.Name = name;
}
protected override void OnDelete(string key)
{
ProfileMap.Remove(key);
Config.Save();
}
}

View File

@@ -0,0 +1,138 @@
using System.Reflection;
using Sandbox.Game;
using VRage;
namespace PluginLoader.GUI;
public class SplashScreen : Form
{
private const float barWidth = 0.98f; // 98% of width
private const float barHeight = 0.06f; // 6% of height
private readonly RectangleF bar;
private readonly PictureBox gifBox;
private readonly bool invalid;
private readonly Label lbl;
private float barValue = float.NaN;
public SplashScreen()
{
Image gif;
if (Application.OpenForms.Count == 0 || !TryLoadImage(out gif))
{
invalid = true;
return;
}
var defaultSplash = Application.OpenForms[0];
Size = defaultSplash.Size;
ClientSize = defaultSplash.ClientSize;
MyVRage.Platform.Windows.HideSplashScreen();
Name = "SplashScreenPluginLoader";
TopMost = true;
FormBorderStyle = FormBorderStyle.None;
var barSize = new SizeF(Size.Width * barWidth, Size.Height * barHeight);
var padding = (1 - barWidth) * Size.Width * 0.5f;
var barStart = new PointF(padding, Size.Height - barSize.Height - padding);
bar = new(barStart, barSize);
var lblFont = new Font(FontFamily.GenericSansSerif, 12, FontStyle.Bold);
lbl = new()
{
Name = "PluginLoaderInfo",
Font = lblFont,
BackColor = Color.Black,
ForeColor = Color.White,
MaximumSize = Size,
Size = new(Size.Width, lblFont.Height),
TextAlign = ContentAlignment.MiddleCenter,
Location = new(0, (int)(barStart.Y - lblFont.Height - 1))
};
Controls.Add(lbl);
gifBox = new()
{
Name = "PluginLoaderAnimation",
Image = gif,
Size = Size,
AutoSize = false,
SizeMode = PictureBoxSizeMode.StretchImage
};
Controls.Add(gifBox);
gifBox.Paint += OnPictureBoxDraw;
CenterToScreen();
Show();
ForceUpdate();
}
public object GameInfo { get; private set; }
private bool TryLoadImage(out Image img)
{
try
{
var myAssembly = Assembly.GetExecutingAssembly();
var myStream = myAssembly.GetManifestResourceStream("PluginLoader.splash.gif");
img = new Bitmap(myStream);
return true;
}
catch
{
img = null;
return false;
}
}
public void SetText(string msg)
{
if (invalid)
return;
lbl.Text = msg;
barValue = float.NaN;
gifBox.Invalidate();
ForceUpdate();
}
public void SetBarValue(float percent = float.NaN)
{
if (invalid)
return;
barValue = percent;
gifBox.Invalidate();
ForceUpdate();
}
private void ForceUpdate()
{
Application.DoEvents();
}
private void OnPictureBoxDraw(object sender, PaintEventArgs e)
{
if (!float.IsNaN(barValue))
{
var graphics = e.Graphics;
graphics.FillRectangle(Brushes.DarkSlateGray, bar);
graphics.FillRectangle(Brushes.White, new RectangleF(bar.Location, new(bar.Width * barValue, bar.Height)));
}
}
public void Delete()
{
if (invalid)
return;
gifBox.Paint -= OnPictureBoxDraw;
Close();
Dispose();
ForceUpdate();
MyVRage.Platform.Windows.ShowSplashScreen(MyPerGameSettings.BasicGameInfo.SplashScreenImage, new(0.7f, 0.7f));
}
}

View File

@@ -0,0 +1,320 @@
using System.Diagnostics.CodeAnalysis;
using Sandbox;
using Sandbox.Game.Gui;
using Sandbox.Game.Localization;
using Sandbox.Graphics.GUI;
using VRage;
using VRage.Game;
using VRage.Utils;
using VRageMath;
using Color = VRageMath.Color;
namespace PluginLoader.GUI;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public abstract class TableDialogBase : MyGuiScreenDebugBase
{
protected readonly string Caption;
protected readonly string DefaultKey;
protected readonly Dictionary<string, string> NamesByKey = new();
protected MyGuiControlButton CancelButton;
protected int ColumnCount;
protected MyGuiControlButton DeleteButton;
protected MyGuiControlButton LoadButton;
protected MyGuiControlButton RenameButton;
protected MyGuiControlTable Table;
protected TableDialogBase(
string caption,
string defaultKey = null)
: base(new(0.5f, 0.5f), new Vector2(1f, 0.8f),
MyGuiConstants.SCREEN_BACKGROUND_COLOR * MySandboxGame.Config.UIBkOpacity, true)
{
Caption = caption;
DefaultKey = defaultKey;
// ReSharper disable once VirtualMemberCallInConstructor
RecreateControls(true);
CanBeHidden = true;
CanHideOthers = true;
CloseButtonEnabled = true;
m_onEnterCallback = LoadAndClose;
}
protected abstract string ItemName { get; }
protected abstract string[] ColumnHeaders { get; }
protected abstract float[] ColumnWidths { get; }
protected abstract object[] ExampleValues { get; }
private Vector2 DialogSize => m_size ?? Vector2.One;
private string SelectedKey => Table.SelectedRow?.UserData as string;
public override string GetFriendlyName()
{
return "ListDialog";
}
protected virtual string NormalizeName(string name)
{
return name.Trim();
}
protected virtual int CompareNames(string a, string b)
{
return string.Compare(a, b, StringComparison.CurrentCultureIgnoreCase);
}
protected abstract IEnumerable<string> IterItemKeys();
protected abstract ItemView GetItemView(string key);
protected abstract void OnLoad(string key);
protected abstract void OnRenamed(string key, string name);
protected abstract void OnDelete(string key);
public override void RecreateControls(bool constructor)
{
base.RecreateControls(constructor);
AddCaption(Caption, Color.White.ToVector4(), new Vector2(0.0f, 0.003f));
CreateTable();
CreateButtons();
}
private void CreateTable()
{
var columnHeaders = ColumnHeaders;
var columnWidths = ColumnWidths;
if (columnHeaders == null || columnWidths == null)
return;
ColumnCount = columnHeaders.Length;
Table = new()
{
Position = new(0.001f, -0.5f * DialogSize.Y + 0.1f),
Size = new(0.85f * DialogSize.X, DialogSize.Y - 0.25f),
OriginAlign = MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_TOP,
ColumnsCount = ColumnCount,
VisibleRowsCount = 15
};
Table.SetCustomColumnWidths(columnWidths);
var exampleValues = ExampleValues;
for (var colIdx = 0; colIdx < ColumnCount; colIdx++)
{
Table.SetColumnName(colIdx, new(columnHeaders[colIdx]));
switch (exampleValues[colIdx])
{
case int _:
Table.SetColumnComparison(colIdx, CellIntComparison);
break;
default:
Table.SetColumnComparison(colIdx, CellTextComparison);
break;
}
}
AddItems();
Table.SortByColumn(0);
Table.ItemDoubleClicked += OnItemDoubleClicked;
Controls.Add(Table);
}
private int CellTextComparison(MyGuiControlTable.Cell x, MyGuiControlTable.Cell y)
{
var a = NormalizeName(x.Text.ToString());
var b = NormalizeName(y.Text.ToString());
return CompareNames(a, b);
}
private int CellIntComparison(MyGuiControlTable.Cell x, MyGuiControlTable.Cell y)
{
return (x.UserData as int? ?? 0) - (y.UserData as int? ?? 0);
}
private void CreateButtons()
{
LoadButton = new(
visualStyle: MyGuiControlButtonStyleEnum.Default,
originAlign: MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_CENTER,
text: new("Load"), onButtonClick: OnLoadButtonClick);
RenameButton = new(
visualStyle: MyGuiControlButtonStyleEnum.Small,
originAlign: MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_CENTER,
text: new("Rename"), onButtonClick: OnRenameButtonClick);
DeleteButton = new(
visualStyle: MyGuiControlButtonStyleEnum.Small,
originAlign: MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_CENTER,
text: new("Delete"), onButtonClick: OnDeleteButtonClick);
CancelButton = new(
visualStyle: MyGuiControlButtonStyleEnum.Default,
originAlign: MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_CENTER,
text: MyTexts.Get(MyCommonTexts.Cancel), onButtonClick: OnCancelButtonClick);
var xs = 0.85f * DialogSize.X;
var y = 0.5f * (DialogSize.Y - 0.15f);
LoadButton.Position = new(-0.39f * xs, y);
RenameButton.Position = new(-0.08f * xs, y);
DeleteButton.Position = new(0.08f * xs, y);
CancelButton.Position = new(0.39f * xs, y);
LoadButton.SetToolTip($"Loads the selected {ItemName}");
RenameButton.SetToolTip($"Renames the selected {ItemName}");
DeleteButton.SetToolTip($"Deletes the selected {ItemName}");
CancelButton.SetToolTip(MyTexts.GetString(MySpaceTexts.ToolTipOptionsSpace_Cancel));
Controls.Add(LoadButton);
Controls.Add(RenameButton);
Controls.Add(DeleteButton);
Controls.Add(CancelButton);
}
private void AddItems()
{
NamesByKey.Clear();
foreach (var key in IterItemKeys())
AddRow(key);
if (TryFindRow(DefaultKey, out var rowIdx))
Table.SelectedRowIndex = rowIdx;
}
private void AddRow(string key)
{
var view = GetItemView(key);
if (view == null)
return;
var row = new MyGuiControlTable.Row(key);
for (var i = 0; i < ColumnCount; i++)
row.AddCell(new(view.Labels[i], view.Values[i]));
Table.Add(row);
NamesByKey[key] = view.Labels[0];
}
private void OnItemDoubleClicked(MyGuiControlTable table, MyGuiControlTable.EventArgs args)
{
LoadAndClose();
}
private void OnLoadButtonClick(MyGuiControlButton _)
{
LoadAndClose();
}
private void LoadAndClose()
{
if (string.IsNullOrEmpty(SelectedKey))
return;
OnLoad(SelectedKey);
CloseScreen();
}
private void OnCancelButtonClick(MyGuiControlButton _)
{
CloseScreen();
}
private void OnRenameButtonClick(MyGuiControlButton _)
{
if (string.IsNullOrEmpty(SelectedKey))
return;
if (!NamesByKey.TryGetValue(SelectedKey, out var oldName))
return;
MyGuiSandbox.AddScreen(new NameDialog(newName => OnNewNameSpecified(SelectedKey, newName),
$"Rename saved {ItemName}", oldName));
}
private void OnNewNameSpecified(string key, string newName)
{
newName = NormalizeName(newName);
if (!TryFindRow(key, out var rowIdx))
return;
OnRenamed(key, newName);
var view = GetItemView(key);
NamesByKey[key] = view.Labels[0];
var row = Table.GetRow(rowIdx);
for (var colIdx = 0; colIdx < ColumnCount; colIdx++)
{
var cell = row.GetCell(colIdx);
var sb = cell.Text;
sb.Clear();
sb.Append(view.Labels[colIdx]);
}
Table.Sort();
}
private void OnDeleteButtonClick(MyGuiControlButton _)
{
var key = SelectedKey;
if (string.IsNullOrEmpty(key))
return;
var name = NamesByKey.GetValueOrDefault(key) ?? "?";
MyGuiSandbox.AddScreen(
MyGuiSandbox.CreateMessageBox(buttonType: MyMessageBoxButtonsType.YES_NO,
messageText: new(
$"Are you sure to delete this saved {ItemName}?\r\n\r\n{name}"),
messageCaption: new("Confirmation"),
callback: result => OnDeleteForSure(result, key)));
}
private void OnDeleteForSure(MyGuiScreenMessageBox.ResultEnum result, string key)
{
if (result != MyGuiScreenMessageBox.ResultEnum.YES)
return;
NamesByKey.Remove(key);
if (TryFindRow(key, out var rowIdx))
Table.Remove(Table.GetRow(rowIdx));
OnDelete(key);
}
private bool TryFindRow(string key, out int index)
{
if (key == null)
{
index = -1;
return false;
}
var count = Table.RowsCount;
for (index = 0; index < count; index++)
if (Table.GetRow(index).UserData as string == key)
return true;
index = -1;
return false;
}
}

255
PluginLoader/LoaderTools.cs Normal file
View File

@@ -0,0 +1,255 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using Windows.UI.Popups;
using HarmonyLib;
using PluginLoader.SEPM;
using Sandbox;
using Sandbox.Game.World;
using Sandbox.Graphics.GUI;
using VRage.FileSystem;
using VRage.Input;
using VRage.Plugins;
namespace PluginLoader;
public static class LoaderTools
{
public static string PluginsDir => Path.GetFullPath(Path.Combine(MyFileSystem.ExePath, "Plugins"));
public static DialogResult ShowMessageBox(string message, string title, MessageBoxButtons buttons, MessageBoxIcon icon)
{
var dialog = new MessageDialog(message, title);
switch (buttons)
{
case MessageBoxButtons.OK:
dialog.Commands.Add(new UICommand("Ok"));
break;
case MessageBoxButtons.OKCancel:
dialog.Commands.Add(new UICommand("Ok"));
dialog.Commands.Add(new UICommand("Cancel"));
break;
case MessageBoxButtons.AbortRetryIgnore:
break;
case MessageBoxButtons.YesNoCancel:
dialog.Commands.Add(new UICommand("Yes"));
dialog.Commands.Add(new UICommand("No"));
dialog.Commands.Add(new UICommand("Cancel"));
break;
case MessageBoxButtons.YesNo:
dialog.Commands.Add(new UICommand("Yes"));
dialog.Commands.Add(new UICommand("No"));
break;
case MessageBoxButtons.RetryCancel:
dialog.Commands.Add(new UICommand("Retry"));
dialog.Commands.Add(new UICommand("Cancel"));
break;
case MessageBoxButtons.CancelTryContinue:
break;
default:
throw new ArgumentOutOfRangeException(nameof(buttons), buttons, null);
}
WinRT.Interop.InitializeWithWindow.Initialize(dialog, Process.GetCurrentProcess().MainWindowHandle);
var result = dialog.ShowAsync().AsTask().Result;
return buttons switch
{
MessageBoxButtons.OK => DialogResult.OK,
MessageBoxButtons.OKCancel => result.Label == "Ok" ? DialogResult.OK : DialogResult.Cancel,
MessageBoxButtons.AbortRetryIgnore => DialogResult.Ignore,
MessageBoxButtons.YesNoCancel => result.Label switch
{
"Yes" => DialogResult.Yes,
"No" => DialogResult.No,
_ => DialogResult.Cancel
},
MessageBoxButtons.YesNo => result.Label switch
{
"Yes" => DialogResult.Yes,
_ => DialogResult.No
},
MessageBoxButtons.RetryCancel => result.Label switch
{
"Retry" => DialogResult.Retry,
_ => DialogResult.Cancel
},
MessageBoxButtons.CancelTryContinue => DialogResult.Cancel,
_ => throw new ArgumentOutOfRangeException(nameof(buttons), buttons, null)
};
}
public static void UnloadAndRestart()
{
LogFile.Dispose();
MySessionLoader.Unload();
MySandboxGame.Config.ControllerDefaultOnStart = MyInput.Static.IsJoystickLastUsed;
MySandboxGame.Config.Save();
MyScreenManager.CloseAllScreensNowExcept(null);
MyPlugins.Unload();
Restart();
}
public static void Restart()
{
Application.Restart();
Process.GetCurrentProcess().Kill();
}
public static void ExecuteMain(SEPMPlugin plugin)
{
var name = plugin.GetType().ToString();
plugin.Main(new(name), new());
}
public static string GetHash1(string file)
{
using (var sha = new SHA1Managed())
{
return GetHash(file, sha);
}
}
public static string GetHash256(string file)
{
using (var sha = new SHA256CryptoServiceProvider())
{
return GetHash(file, sha);
}
}
public static string GetHash(string file, HashAlgorithm hash)
{
using (var fileStream = new FileStream(file, FileMode.Open))
{
using (var bufferedStream = new BufferedStream(fileStream))
{
var data = hash.ComputeHash(bufferedStream);
var sb = new StringBuilder(2 * data.Length);
foreach (var b in data)
sb.AppendFormat("{0:x2}", b);
return sb.ToString();
}
}
}
/// <summary>
/// This method attempts to disable JIT compiling for the assembly.
/// This method will force any member access exceptions by methods to be thrown now instead of later.
/// </summary>
public static void Precompile(Assembly a)
{
Type[] types;
try
{
types = a.GetTypes();
}
catch (ReflectionTypeLoadException e)
{
var sb = new StringBuilder();
sb.AppendLine("LoaderExceptions: ");
foreach (var e2 in e.LoaderExceptions)
sb.Append(e2).AppendLine();
LogFile.WriteLine(sb.ToString());
throw;
}
foreach (var t in types)
{
// Static constructors allow for early code execution which can cause issues later in the game
if (HasStaticConstructor(t))
continue;
foreach (var m in t.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.NonPublic | BindingFlags.Public |
BindingFlags.Instance | BindingFlags.Static))
{
if (m.HasAttribute<HarmonyReversePatch>())
throw new("Harmony attribute 'HarmonyReversePatch' found on the method '" + m.Name +
"' is not compatible with Plugin Loader!");
Precompile(m);
}
}
}
private static void Precompile(MethodInfo m)
{
if (!m.IsAbstract && !m.ContainsGenericParameters)
RuntimeHelpers.PrepareMethod(m.MethodHandle);
}
private static bool HasStaticConstructor(Type t)
{
return t.GetConstructors(BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic |
BindingFlags.Instance).Any(c => c.IsStatic);
}
public static void OpenFileDialog(string title, string directory, string filter, Action<string> onOk)
{
var t = new Thread(() => OpenFileDialogThread(title, directory, filter, onOk));
t.SetApartmentState(ApartmentState.STA);
t.Start();
}
private static void OpenFileDialogThread(string title, string directory, string filter, Action<string> onOk)
{
try
{
// Get the file path via prompt
using (var openFileDialog = new OpenFileDialog())
{
if (Directory.Exists(directory))
openFileDialog.InitialDirectory = directory;
openFileDialog.Title = title;
openFileDialog.Filter = filter;
openFileDialog.RestoreDirectory = true;
if (openFileDialog.ShowDialog() == DialogResult.OK)
// Move back to the main thread so that we can interact with keen code again
MySandboxGame.Static.Invoke(
() => onOk(openFileDialog.FileName),
"PluginLoader");
}
}
catch (Exception e)
{
LogFile.WriteGameLog("Error while opening file dialog: " + e);
}
}
public static void OpenFolderDialog(string title, string directory, Action<string> onOk)
{
var t = new Thread(() => OpenFolderDialogThread(title, directory, onOk));
t.SetApartmentState(ApartmentState.STA);
t.Start();
}
private static void OpenFolderDialogThread(string title, string directory, Action<string> onOk)
{
try
{
// Get the file path via prompt
using (var openFileDialog = new FolderBrowserDialog())
{
if (Directory.Exists(directory))
openFileDialog.SelectedPath = directory;
openFileDialog.Description = title;
if (openFileDialog.ShowDialog() == DialogResult.OK)
// Move back to the main thread so that we can interact with keen code again
MySandboxGame.Static.Invoke(
() => onOk(openFileDialog.SelectedPath),
"PluginLoader");
}
}
catch (Exception e)
{
LogFile.WriteGameLog("Error while opening file dialog: " + e);
}
}
}

77
PluginLoader/LogFile.cs Normal file
View File

@@ -0,0 +1,77 @@
using VRage.Utils;
namespace PluginLoader;
public static class LogFile
{
private const string fileName = "loader.log";
private static StreamWriter writer;
public static void Init(string mainPath)
{
var file = Path.Combine(mainPath, fileName);
try
{
writer = File.CreateText(file);
}
catch
{
writer = null;
}
}
/// <summary>
/// Writes the specifed text to the log file.
/// WARNING: Not thread safe!
/// </summary>
public static void WriteLine(string text, bool gameLog = true)
{
try
{
writer?.WriteLine($"{DateTime.UtcNow:O} {text}");
if (gameLog)
WriteGameLog(text);
writer?.Flush();
}
catch
{
Dispose();
}
}
/// <summary>
/// Writes the specifed text to the game log file.
/// This function is thread safe.
/// </summary>
public static void WriteGameLog(string text)
{
MyLog.Default.WriteLine($"[PluginLoader] {text}");
}
public static void WriteTrace(string text, bool gameLog = true)
{
#if DEBUG
writer?.WriteLine($"{DateTime.UtcNow:O} {text}");
if (gameLog)
WriteGameLog($"[PluginLoader] {text}");
writer?.Flush();
#endif
}
public static void Dispose()
{
if (writer == null)
return;
try
{
writer.Flush();
writer.Close();
}
catch
{
}
writer = null;
}
}

243
PluginLoader/Main.cs Normal file
View File

@@ -0,0 +1,243 @@
using System.Diagnostics;
using System.Net;
using System.Reflection;
using HarmonyLib;
using PluginLoader.Compiler;
using PluginLoader.Data;
using PluginLoader.GUI;
using PluginLoader.Stats;
using Sandbox.Game.World;
using VRage.Plugins;
using SEPMPlugin = PluginLoader.SEPM.SEPMPlugin;
namespace PluginLoader;
public class Main : IHandleInputPlugin
{
private const string HarmonyVersion = "2.2.1.0";
public static Main Instance;
private readonly List<PluginInstance> plugins = new();
private bool init;
public Main()
{
var sw = Stopwatch.StartNew();
Splash = new();
Instance = this;
var temp = Cursor.Current;
Cursor.Current = Cursors.AppStarting;
var pluginsDir = LoaderTools.PluginsDir;
Directory.CreateDirectory(pluginsDir);
LogFile.Init(pluginsDir);
LogFile.WriteLine("Starting - v" + Assembly.GetExecutingAssembly().GetName().Version.ToString(3));
// Fix tls 1.2 not supported on Windows 7 - github.com is tls 1.2 only
try
{
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
}
catch (NotSupportedException e)
{
LogFile.WriteLine("An error occurred while setting up networking, web requests will probably fail: " + e);
}
Splash.SetText("Finding references...");
RoslynReferences.GenerateAssemblyList();
AppDomain.CurrentDomain.AssemblyResolve += ResolveDependencies;
Config = PluginConfig.Load(pluginsDir);
List = new(pluginsDir, Config);
Config.Init(List);
StatsClient.OverrideBaseUrl(Config.StatsServerBaseUrl);
Splash.SetText("Patching...");
LogFile.WriteLine("Patching");
// Check harmony version
var expectedHarmony = new Version(HarmonyVersion);
var actualHarmony = typeof(Harmony).Assembly.GetName().Version;
if (expectedHarmony != actualHarmony)
LogFile.WriteLine(
$"WARNING: Unexpected Harmony version, plugins may be unstable. Expected {expectedHarmony} but found {actualHarmony}");
new Harmony("avaness.PluginLoader").PatchAll(Assembly.GetExecutingAssembly());
Splash.SetText("Instantiating plugins...");
LogFile.WriteLine("Instantiating plugins");
foreach (var id in Config)
{
var data = List[id];
if (data is GitHubPlugin github)
github.Init(pluginsDir);
if (PluginInstance.TryGet(data, out var p))
{
plugins.Add(p);
if (data.IsLocal)
HasLocal = true;
}
}
sw.Stop();
// FIXME: It can potentially run in the background speeding up the game's startup
ReportEnabledPlugins();
LogFile.WriteLine($"Finished startup. Took {sw.ElapsedMilliseconds}ms");
Cursor.Current = temp;
Splash.Delete();
Splash = null;
}
public PluginList List { get; }
public PluginConfig Config { get; }
public SplashScreen Splash { get; }
/// <summary>
/// True if a local plugin was loaded
/// </summary>
public bool HasLocal { get; }
// Skip local plugins, keep only enabled ones
public string[] TrackablePluginIds => Config.EnabledPlugins.Where(id => !List[id].IsLocal).ToArray();
public void Init(object gameInstance)
{
LogFile.WriteLine($"Initializing {plugins.Count} plugins");
for (var i = plugins.Count - 1; i >= 0; i--)
{
var p = plugins[i];
if (!p.Init(gameInstance))
plugins.RemoveAtFast(i);
}
init = true;
}
public void Update()
{
if (init)
for (var i = plugins.Count - 1; i >= 0; i--)
{
var p = plugins[i];
if (!p.Update())
plugins.RemoveAtFast(i);
}
}
public void HandleInput()
{
if (init)
for (var i = plugins.Count - 1; i >= 0; i--)
{
var p = plugins[i];
if (!p.HandleInput())
plugins.RemoveAtFast(i);
}
}
public void Dispose()
{
foreach (var p in plugins)
p.Dispose();
plugins.Clear();
AppDomain.CurrentDomain.AssemblyResolve -= ResolveDependencies;
LogFile.Dispose();
Instance = null;
}
public bool TryGetPluginInstance(string id, out PluginInstance instance)
{
instance = null;
if (!init)
return false;
foreach (var p in plugins)
if (p.Id == id)
{
instance = p;
return true;
}
return false;
}
private void ReportEnabledPlugins()
{
if (!PlayerConsent.ConsentGiven)
return;
Splash.SetText("Reporting plugin usage...");
LogFile.WriteLine("Reporting plugin usage");
// Config has already been validated at this point so all enabled plugins will have list items
// FIXME: Move into a background thread
if (StatsClient.Track(TrackablePluginIds))
LogFile.WriteLine("List of enabled plugins has been sent to the statistics server");
else
LogFile.WriteLine("Failed to send the list of enabled plugins to the statistics server");
}
public void RegisterComponents()
{
LogFile.WriteLine($"Registering {plugins.Count} components");
foreach (var plugin in plugins)
plugin.RegisterSession(MySession.Static);
}
public void DisablePlugins()
{
Config.Disable();
plugins.Clear();
LogFile.WriteLine("Disabled all plugins");
}
public void InstantiatePlugins()
{
LogFile.WriteLine($"Loading {plugins.Count} plugins");
for (var i = plugins.Count - 1; i >= 0; i--)
{
var p = plugins[i];
if (!p.Instantiate())
plugins.RemoveAtFast(i);
}
}
private Assembly ResolveDependencies(object sender, ResolveEventArgs args)
{
var assembly = args.RequestingAssembly?.GetName().ToString();
if (args.Name.Contains("0Harmony"))
{
if (assembly != null)
LogFile.WriteLine("Resolving 0Harmony for " + assembly);
else
LogFile.WriteLine("Resolving 0Harmony");
return typeof(Harmony).Assembly;
}
if (args.Name.Contains("SEPluginManager"))
{
if (assembly != null)
LogFile.WriteLine("Resolving SEPluginManager for " + assembly);
else
LogFile.WriteLine("Resolving SEPluginManager");
return typeof(SEPMPlugin).Assembly;
}
return null;
}
}

View File

@@ -0,0 +1,48 @@
using System.Net;
namespace PluginLoader.Network;
public static class GitHub
{
public const string listRepoName = "sepluginloader/PluginHub";
public const string listRepoCommit = "main";
public const string listRepoHash = "plugins.sha1";
private const string repoZipUrl = "https://github.com/{0}/archive/{1}.zip";
private const string rawUrl = "https://raw.githubusercontent.com/{0}/{1}/";
public static Stream DownloadRepo(string name, string commit, out string fileName)
{
var uri = new Uri(string.Format(repoZipUrl, name, commit), UriKind.Absolute);
LogFile.WriteLine("Downloading " + uri);
var request = WebRequest.CreateHttp(uri);
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
request.Timeout = Main.Instance.Config.NetworkTimeout;
var response = (HttpWebResponse)request.GetResponse();
fileName = response.Headers["Content-Disposition"];
if (fileName != null)
{
var index = fileName.IndexOf("filename=");
if (index >= 0)
{
index += "filename=".Length;
fileName = fileName.Substring(index).Trim('"');
}
}
return response.GetResponseStream();
}
public static Stream DownloadFile(string name, string commit, string path)
{
var uri = new Uri(string.Format(rawUrl, name, commit) + path.TrimStart('/'), UriKind.Absolute);
LogFile.WriteLine("Downloading " + uri);
var request = WebRequest.CreateHttp(uri);
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
request.Timeout = Main.Instance.Config.NetworkTimeout;
var response = (HttpWebResponse)request.GetResponse();
return response.GetResponseStream();
}
}

View File

@@ -0,0 +1,18 @@
using System.Reflection;
using HarmonyLib;
using Sandbox.Game.World;
using VRage.Game;
using VRage.Plugins;
namespace PluginLoader.Patch;
[HarmonyPatch(typeof(MySession), "RegisterComponentsFromAssembly")]
[HarmonyPatch(new[] { typeof(Assembly), typeof(bool), typeof(MyModContext) })]
public static class Patch_ComponentRegistered
{
public static void Prefix(Assembly assembly)
{
if (assembly == MyPlugins.GameAssembly)
Main.Instance?.RegisterComponents();
}
}

View File

@@ -0,0 +1,60 @@
using HarmonyLib;
using PluginLoader.GUI;
using Sandbox.Graphics.GUI;
using SpaceEngineers.Game.GUI;
using VRage.Game;
using VRage.Utils;
using VRageMath;
// ReSharper disable InconsistentNaming
namespace PluginLoader.Patch;
[HarmonyPatch(typeof(MyGuiScreenMainMenu), "CreateMainMenu")]
public static class Patch_CreateMainMenu
{
public static void Postfix(MyGuiScreenMainMenu __instance, Vector2 leftButtonPositionOrigin,
ref Vector2 lastButtonPosition)
{
MyGuiControlButton lastBtn = null;
foreach (var control in __instance.Controls)
if (control is MyGuiControlButton btn && btn.Position == lastButtonPosition)
{
lastBtn = btn;
break;
}
Vector2 position;
if (lastBtn == null)
{
position = lastButtonPosition + MyGuiConstants.MENU_BUTTONS_POSITION_DELTA;
}
else
{
position = lastBtn.Position;
lastBtn.Position = lastButtonPosition + MyGuiConstants.MENU_BUTTONS_POSITION_DELTA;
}
var openBtn = new MyGuiControlButton(position, MyGuiControlButtonStyleEnum.StripeLeft,
originAlign: MyGuiDrawAlignEnum.HORISONTAL_CENTER_AND_VERTICAL_BOTTOM,
text: new("Plugins"),
onButtonClick: _ => MyGuiScreenPluginConfig.OpenMenu())
{
BorderEnabled = false,
BorderSize = 0,
BorderHighlightEnabled = false,
BorderColor = Vector4.Zero
};
__instance.Controls.Add(openBtn);
}
}
[HarmonyPatch(typeof(MyGuiScreenMainMenu), "CreateInGameMenu")]
public static class Patch_CreateInGameMenu
{
public static void Postfix(MyGuiScreenMainMenu __instance, Vector2 leftButtonPositionOrigin,
ref Vector2 lastButtonPosition)
{
Patch_CreateMainMenu.Postfix(__instance, leftButtonPositionOrigin, ref lastButtonPosition);
}
}

View File

@@ -0,0 +1,26 @@
using HarmonyLib;
using Sandbox;
using VRage.Input;
namespace PluginLoader.Patch;
[HarmonyPatch(typeof(MySandboxGame), "LoadData")]
public static class Patch_DisableConfig
{
public static void Postfix()
{
// This is the earliest point in which I can use MyInput.Static
if (Main.Instance == null)
return;
var main = Main.Instance;
var config = main.Config;
if (config != null && config.Count > 0 && MyInput.Static is MyVRageInput &&
MyInput.Static.IsKeyPress(MyKeys.Escape)
&& LoaderTools.ShowMessageBox("Escape pressed. Start the game with all plugins disabled?",
"Plugin Loader", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
main.DisablePlugins();
else
main.InstantiatePlugins();
}
}

View File

@@ -0,0 +1,38 @@
using HarmonyLib;
using Sandbox.Game.Gui;
using Sandbox.Graphics.GUI;
using VRage;
using VRage.Input;
namespace PluginLoader.Patch;
[HarmonyPatch(typeof(MyGuiScreenGamePlay), "ShowLoadMessageBox")]
public static class Patch_IngameRestart
{
public static bool Prefix()
{
if (Main.Instance.HasLocal && MyInput.Static.IsAnyAltKeyPressed() && MyInput.Static.IsAnyCtrlKeyPressed())
{
ShowRestartMenu();
return false;
}
return true;
}
public static void ShowRestartMenu()
{
var box = MyGuiSandbox.CreateMessageBox(MyMessageBoxStyleEnum.Error, MyMessageBoxButtonsType.YES_NO,
new("Plugin Loader: Are you sure you want to restart the game?"),
MyTexts.Get(MyCommonTexts.MessageBoxCaptionPleaseConfirm),
callback: OnMessageClosed);
box.SkipTransition = true;
box.CloseBeforeCallback = true;
MyGuiSandbox.AddScreen(box);
}
private static void OnMessageClosed(MyGuiScreenMessageBox.ResultEnum result)
{
if (result == MyGuiScreenMessageBox.ResultEnum.YES) LoaderTools.UnloadAndRestart();
}
}

View File

@@ -0,0 +1,37 @@
using HarmonyLib;
using PluginLoader.Data;
using Sandbox.Definitions;
using VRage.Game;
namespace PluginLoader.Patch;
[HarmonyPatch(typeof(MyDefinitionManager), "LoadData")]
public static class Patch_MyDefinitionManager
{
public static void Prefix(ref List<MyObjectBuilder_Checkpoint.ModItem> mods)
{
try
{
var currentMods = new HashSet<ulong>(mods.Select(x => x.PublishedFileId));
var newMods = new List<MyObjectBuilder_Checkpoint.ModItem>(mods);
var list = Main.Instance.List;
foreach (var id in Main.Instance.Config.EnabledPlugins)
{
var data = list[id];
if (data is ModPlugin mod && !currentMods.Contains(mod.WorkshopId) && mod.Exists)
{
LogFile.WriteLine("Loading client mod definitions for " + mod.WorkshopId);
newMods.Add(mod.GetModItem());
}
}
mods = newMods;
}
catch (Exception e)
{
LogFile.WriteLine("An error occured while loading client mods: " + e);
throw;
}
}
}

View File

@@ -0,0 +1,48 @@
using System.Reflection;
using HarmonyLib;
using PluginLoader.Data;
using Sandbox.Game.World;
using VRage.Game;
namespace PluginLoader.Patch;
[HarmonyPatch(typeof(MyScriptManager), "LoadData")]
public static class Patch_MyScripManager
{
private static readonly Action<MyScriptManager, string, MyModContext> loadScripts;
static Patch_MyScripManager()
{
loadScripts = (Action<MyScriptManager, string, MyModContext>)Delegate.CreateDelegate(
typeof(Action<MyScriptManager, string, MyModContext>),
typeof(MyScriptManager).GetMethod("LoadScripts", BindingFlags.Instance | BindingFlags.NonPublic));
}
public static void Postfix(MyScriptManager __instance)
{
try
{
HashSet<ulong> currentMods;
if (MySession.Static.Mods != null)
currentMods = new(MySession.Static.Mods.Select(x => x.PublishedFileId));
else
currentMods = new();
var list = Main.Instance.List;
foreach (var id in Main.Instance.Config.EnabledPlugins)
{
var data = list[id];
if (data is ModPlugin mod && !currentMods.Contains(mod.WorkshopId) && mod.Exists)
{
LogFile.WriteLine("Loading client mod scripts for " + mod.WorkshopId);
loadScripts(__instance, mod.ModLocation, mod.GetModContext());
}
}
}
catch (Exception e)
{
LogFile.WriteLine("An error occured while loading client mods: " + e);
throw;
}
}
}

View File

@@ -0,0 +1,181 @@
using System.Text;
using System.Xml.Serialization;
using PluginLoader.Data;
namespace PluginLoader;
public class PluginConfig
{
private const string fileName = "config.xml";
[XmlIgnore] public readonly Dictionary<string, Profile> ProfileMap = new();
private string filePath;
private int networkTimeout = 5000;
[XmlArray]
[XmlArrayItem("Id")]
public string[] Plugins
{
get => EnabledPlugins.ToArray();
set => EnabledPlugins = new(value);
}
[XmlIgnore] public HashSet<string> EnabledPlugins { get; private set; } = new();
[XmlArray]
[XmlArrayItem("Plugin")]
public LocalFolderPlugin.Config[] LocalFolderPlugins
{
get => PluginFolders.Values.ToArray();
set { PluginFolders = value.ToDictionary(x => x.Folder); }
}
[XmlIgnore] public Dictionary<string, LocalFolderPlugin.Config> PluginFolders { get; private set; } = new();
[XmlArray]
[XmlArrayItem("Profile")]
public Profile[] Profiles
{
get => ProfileMap.Values.ToArray();
set
{
ProfileMap.Clear();
foreach (var profile in value)
ProfileMap[profile.Key] = profile;
}
}
public string ListHash { get; set; }
// Base URL for the statistics server, change to http://localhost:5000 in config.xml for local development
// ReSharper disable once UnassignedGetOnlyAutoProperty
public string StatsServerBaseUrl { get; }
// User consent to use the StatsServer
public bool DataHandlingConsent { get; set; }
public string DataHandlingConsentDate { get; set; }
public int NetworkTimeout
{
get => networkTimeout;
set
{
if (value < 100)
networkTimeout = 100;
else if (value > 60000)
networkTimeout = 60000;
else
networkTimeout = value;
}
}
public int Count => EnabledPlugins.Count;
public void Init(PluginList plugins)
{
// Remove plugins from config that no longer exist
var toRemove = new List<string>();
var sb = new StringBuilder("Enabled plugins: ");
foreach (var id in EnabledPlugins)
if (!plugins.Contains(id))
{
LogFile.WriteLine($"{id} was in the config but is no longer available");
toRemove.Add(id);
}
else
{
sb.Append(id).Append(", ");
}
if (EnabledPlugins.Count > 0)
sb.Length -= 2;
else
sb.Append("None");
LogFile.WriteLine(sb.ToString());
foreach (var id in toRemove)
EnabledPlugins.Remove(id);
if (toRemove.Count > 0)
Save();
}
public void Disable()
{
EnabledPlugins.Clear();
}
public void Save()
{
try
{
LogFile.WriteLine("Saving config");
var serializer = new XmlSerializer(typeof(PluginConfig));
if (File.Exists(filePath))
File.Delete(filePath);
var fs = File.OpenWrite(filePath);
serializer.Serialize(fs, this);
fs.Flush();
fs.Close();
}
catch (Exception e)
{
LogFile.WriteLine("An error occurred while saving plugin config: " + e);
}
}
public static PluginConfig Load(string mainDirectory)
{
var path = Path.Combine(mainDirectory, fileName);
if (File.Exists(path))
try
{
var serializer = new XmlSerializer(typeof(PluginConfig));
var fs = File.OpenRead(path);
var config = (PluginConfig)serializer.Deserialize(fs);
fs.Close();
config.filePath = path;
return config;
}
catch (Exception e)
{
LogFile.WriteLine("An error occurred while loading plugin config: " + e);
}
return new()
{
filePath = path
};
}
public IEnumerator<string> GetEnumerator()
{
return EnabledPlugins.GetEnumerator();
}
public bool IsEnabled(string id)
{
return EnabledPlugins.Contains(id);
}
public void SetEnabled(string id, bool enabled)
{
if (EnabledPlugins.Contains(id) == enabled)
return;
if (enabled)
{
EnabledPlugins.Add(id);
Main.Instance.List.SubscribeToItem(id);
}
else
{
EnabledPlugins.Remove(id);
}
}
}

View File

@@ -0,0 +1,176 @@
using System.Reflection;
using HarmonyLib;
using PluginLoader.Data;
using Sandbox.Game.World;
using VRage.Game.Components;
using VRage.Plugins;
using SEPMPlugin = PluginLoader.SEPM.SEPMPlugin;
namespace PluginLoader;
public class PluginInstance
{
private readonly PluginData data;
private readonly Assembly mainAssembly;
private readonly Type mainType;
private IHandleInputPlugin inputPlugin;
private MethodInfo openConfigDialog;
private IPlugin plugin;
private PluginInstance(PluginData data, Assembly mainAssembly, Type mainType)
{
this.data = data;
this.mainAssembly = mainAssembly;
this.mainType = mainType;
}
public string Id => data.Id;
public bool HasConfigDialog => openConfigDialog != null;
public bool Instantiate()
{
try
{
plugin = (IPlugin)Activator.CreateInstance(mainType);
inputPlugin = plugin as IHandleInputPlugin;
}
catch (Exception e)
{
ThrowError($"Failed to instantiate {data} because of an error: {e}");
return false;
}
try
{
openConfigDialog = AccessTools.DeclaredMethod(mainType, "OpenConfigDialog", Array.Empty<Type>());
}
catch (Exception e)
{
LogFile.WriteLine($"Unable to find OpenConfigDialog() in {data} due to an error: {e}");
openConfigDialog = null;
}
return true;
}
public void OpenConfig()
{
if (plugin == null || openConfigDialog == null)
return;
try
{
openConfigDialog.Invoke(plugin, Array.Empty<object>());
}
catch (Exception e)
{
ThrowError($"Failed to open plugin config for {data} because of an error: {e}");
}
}
public bool Init(object gameInstance)
{
if (plugin == null)
return false;
try
{
if (plugin is SEPMPlugin sepm)
LoaderTools.ExecuteMain(sepm);
plugin.Init(gameInstance);
return true;
}
catch (Exception e)
{
ThrowError($"Failed to initialize {data} because of an error: {e}");
return false;
}
}
public void RegisterSession(MySession session)
{
if (plugin != null)
try
{
var descType = typeof(MySessionComponentDescriptor);
var count = 0;
foreach (var t in mainAssembly.GetTypes().Where(t => Attribute.IsDefined(t, descType)))
{
var comp = (MySessionComponentBase)Activator.CreateInstance(t);
session.RegisterComponent(comp, comp.UpdateOrder, comp.Priority);
count++;
}
if (count > 0)
LogFile.WriteLine($"Registered {count} session components from: {mainAssembly.FullName}");
}
catch (Exception e)
{
ThrowError($"Failed to register {data} because of an error: {e}");
}
}
public bool Update()
{
if (plugin == null)
return false;
plugin.Update();
return true;
}
public bool HandleInput()
{
if (plugin == null)
return false;
inputPlugin?.HandleInput();
return true;
}
public void Dispose()
{
if (plugin != null)
try
{
plugin.Dispose();
plugin = null;
inputPlugin = null;
}
catch (Exception e)
{
data.Status = PluginStatus.Error;
LogFile.WriteLine($"Failed to dispose {data} because of an error: {e}");
}
}
private void ThrowError(string error)
{
LogFile.WriteLine(error);
data.Error();
Dispose();
}
public static bool TryGet(PluginData data, out PluginInstance instance)
{
instance = null;
if (data.Status == PluginStatus.Error || !data.TryLoadAssembly(out var a))
return false;
var pluginType = a.GetTypes().FirstOrDefault(t => typeof(IPlugin).IsAssignableFrom(t));
if (pluginType == null)
{
LogFile.WriteLine($"Failed to load {data} because it does not contain an IPlugin");
data.Error();
return false;
}
instance = new(data, a, pluginType);
return true;
}
public override string ToString()
{
return data.ToString();
}
}

369
PluginLoader/PluginList.cs Normal file
View File

@@ -0,0 +1,369 @@
using System.Collections;
using System.IO.Compression;
using System.Xml.Serialization;
using PluginLoader.Data;
using PluginLoader.Network;
using ProtoBuf;
namespace PluginLoader;
public class PluginList : IEnumerable<PluginData>
{
private Dictionary<string, PluginData> plugins = new();
public PluginList(string mainDirectory, PluginConfig config)
{
var lbl = Main.Instance.Splash;
lbl.SetText("Downloading plugin list...");
DownloadList(mainDirectory, config);
if (plugins.Count == 0)
{
LogFile.WriteLine("WARNING: No plugins in the plugin list. Plugin list will contain local plugins only.");
HasError = true;
}
FindWorkshopPlugins(config);
FindLocalPlugins(config, mainDirectory);
LogFile.WriteLine($"Found {plugins.Count} plugins");
FindPluginGroups();
FindModDependencies();
}
public int Count => plugins.Count;
public bool HasError { get; }
public PluginData this[string key]
{
get => plugins[key];
set => plugins[key] = value;
}
public IEnumerator<PluginData> GetEnumerator()
{
return plugins.Values.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return plugins.Values.GetEnumerator();
}
public bool Contains(string id)
{
return plugins.ContainsKey(id);
}
public bool TryGetPlugin(string id, out PluginData pluginData)
{
return plugins.TryGetValue(id, out pluginData);
}
/// <summary>
/// Ensures the user is subscribed to the steam plugin.
/// </summary>
public void SubscribeToItem(string id)
{
if (plugins.TryGetValue(id, out var data) && data is ISteamItem steam)
SteamAPI.SubscribeToItem(steam.WorkshopId);
}
public bool Remove(string id)
{
return plugins.Remove(id);
}
public void Add(PluginData data)
{
plugins[data.Id] = data;
}
private void FindPluginGroups()
{
var groups = 0;
foreach (var group in plugins.Values.Where(x => !string.IsNullOrWhiteSpace(x.GroupId)).GroupBy(x => x.GroupId))
{
groups++;
foreach (var data in group)
data.Group.AddRange(group.Where(x => x != data));
}
if (groups > 0)
LogFile.WriteLine($"Found {groups} plugin groups");
}
private void FindModDependencies()
{
foreach (var data in plugins.Values)
if (data is ModPlugin mod)
FindModDependencies(mod);
}
private void FindModDependencies(ModPlugin mod)
{
if (mod.DependencyIds == null)
return;
var dependencies = new Dictionary<ulong, ModPlugin>();
dependencies.Add(mod.WorkshopId, mod);
var toProcess = new Stack<ModPlugin>();
toProcess.Push(mod);
while (toProcess.Count > 0)
{
var temp = toProcess.Pop();
if (temp.DependencyIds == null)
continue;
foreach (var id in temp.DependencyIds)
if (!dependencies.ContainsKey(id) && plugins.TryGetValue(id.ToString(), out var data) &&
data is ModPlugin dependency)
{
toProcess.Push(dependency);
dependencies[id] = dependency;
}
}
dependencies.Remove(mod.WorkshopId);
mod.Dependencies = dependencies.Values.ToArray();
}
private void DownloadList(string mainDirectory, PluginConfig config)
{
var whitelist = Path.Combine(mainDirectory, "whitelist.bin");
PluginData[] list;
var currentHash = config.ListHash;
string newHash;
if (!TryDownloadWhitelistHash(out newHash))
{
// No connection to plugin hub, read from cache
if (!TryReadWhitelistFile(whitelist, out list))
return;
}
else if (currentHash == null || currentHash != newHash)
{
// Plugin list changed, try downloading new version first
if (!TryDownloadWhitelistFile(whitelist, newHash, config, out list)
&& !TryReadWhitelistFile(whitelist, out list))
return;
}
else
{
// Plugin list did not change, try reading the current version first
if (!TryReadWhitelistFile(whitelist, out list)
&& !TryDownloadWhitelistFile(whitelist, newHash, config, out list))
return;
}
if (list != null)
plugins = list.ToDictionary(x => x.Id);
}
private bool TryReadWhitelistFile(string file, out PluginData[] list)
{
list = null;
if (File.Exists(file) && new FileInfo(file).Length > 0)
{
LogFile.WriteLine("Reading whitelist from cache");
try
{
using (Stream binFile = File.OpenRead(file))
{
list = Serializer.Deserialize<PluginData[]>(binFile);
}
LogFile.WriteLine("Whitelist retrieved from disk");
return true;
}
catch (Exception e)
{
LogFile.WriteLine("Error while reading whitelist: " + e);
}
}
else
{
LogFile.WriteLine("No whitelist cache exists");
}
return false;
}
private bool TryDownloadWhitelistFile(string file, string hash, PluginConfig config, out PluginData[] list)
{
list = null;
var newPlugins = new Dictionary<string, PluginData>();
try
{
using (var zipFileStream = GitHub.DownloadRepo(GitHub.listRepoName, GitHub.listRepoCommit, out _))
using (var zipFile = new ZipArchive(zipFileStream))
{
var xml = new XmlSerializer(typeof(PluginData));
foreach (var entry in zipFile.Entries)
{
if (!entry.FullName.EndsWith("xml", StringComparison.OrdinalIgnoreCase))
continue;
using (var entryStream = entry.Open())
using (var entryReader = new StreamReader(entryStream))
{
try
{
var data = (PluginData)xml.Deserialize(entryReader);
newPlugins[data.Id] = data;
}
catch (InvalidOperationException e)
{
LogFile.WriteLine("An error occurred while reading the plugin xml: " +
(e.InnerException ?? e));
}
}
}
}
list = newPlugins.Values.ToArray();
return TrySaveWhitelist(file, list, hash, config);
}
catch (Exception e)
{
LogFile.WriteLine("Error while downloading whitelist: " + e);
}
return false;
}
private bool TrySaveWhitelist(string file, PluginData[] list, string hash, PluginConfig config)
{
try
{
LogFile.WriteLine("Saving whitelist to disk");
using (var mem = new MemoryStream())
{
Serializer.Serialize(mem, list);
using (Stream binFile = File.Create(file))
{
mem.WriteTo(binFile);
}
}
config.ListHash = hash;
config.Save();
LogFile.WriteLine("Whitelist updated");
return true;
}
catch (Exception e)
{
LogFile.WriteLine("Error while saving whitelist: " + e);
try
{
File.Delete(file);
}
catch
{
}
return false;
}
}
private bool TryDownloadWhitelistHash(out string hash)
{
hash = null;
try
{
using (var hashStream =
GitHub.DownloadFile(GitHub.listRepoName, GitHub.listRepoCommit, GitHub.listRepoHash))
using (var hashStreamReader = new StreamReader(hashStream))
{
hash = hashStreamReader.ReadToEnd().Trim();
}
return true;
}
catch (Exception e)
{
LogFile.WriteLine("Error while downloading whitelist hash: " + e);
return false;
}
}
private void FindLocalPlugins(PluginConfig config, string mainDirectory)
{
foreach (var dll in Directory.EnumerateFiles(mainDirectory, "*.dll", SearchOption.AllDirectories))
if (!dll.Contains(Path.DirectorySeparatorChar + "GitHub" + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase))
{
var local = new LocalPlugin(dll);
var name = local.FriendlyName;
if (!name.StartsWith("0Harmony") && !name.StartsWith("Microsoft"))
plugins[local.Id] = local;
}
foreach (var folderConfig in config.PluginFolders.Values)
if (folderConfig.Valid)
{
var local = new LocalFolderPlugin(folderConfig);
plugins[local.Id] = local;
}
}
private void FindWorkshopPlugins(PluginConfig config)
{
var steamPlugins = new List<ISteamItem>(plugins.Values.Select(x => x as ISteamItem).Where(x => x != null));
Main.Instance.Splash.SetText("Updating workshop items...");
SteamAPI.Update(steamPlugins.Where(x => config.IsEnabled(x.Id)).Select(x => x.WorkshopId));
var workshop = Path.GetFullPath(@"..\..\..\workshop\content\244850\");
foreach (var steam in steamPlugins)
try
{
var path = Path.Combine(workshop, steam.Id);
if (Directory.Exists(path))
{
if (steam is SteamPlugin plugin && TryGetPlugin(path, out string dllFile))
plugin.Init(dllFile);
}
else if (config.IsEnabled(steam.Id))
{
((PluginData)steam).Status = PluginStatus.Error;
LogFile.WriteLine($"The plugin '{steam}' is missing and cannot be loaded.");
}
}
catch (Exception e)
{
LogFile.WriteLine($"An error occurred while searching for the workshop plugin {steam}: {e}");
}
}
private bool TryGetPlugin(string modRoot, out string pluginFile)
{
foreach (var file in Directory.EnumerateFiles(modRoot, "*.plugin"))
{
var name = Path.GetFileName(file);
if (!name.StartsWith("0Harmony", StringComparison.OrdinalIgnoreCase))
{
pluginFile = file;
return true;
}
}
var sepm = Path.Combine(modRoot, "Data", "sepm-plugin.zip");
if (File.Exists(sepm))
{
pluginFile = sepm;
return true;
}
pluginFile = null;
return false;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.Net.SDK">
<PropertyGroup>
<TargetFramework>net6.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Lib.Harmony" Version="2.2.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="2.9.0" IncludeAssets="compile" PrivateAssets="all" />
<PackageReference Include="protobuf-net" Version="2.4.7" />
<PackageReference Include="SpaceEngineersDedicated.ReferenceAssemblies" Version="1.201.13" IncludeAssets="compile" PrivateAssets="all" />
<PackageReference Include="Steamworks.NET" Version="15.0.1" IncludeAssets="compile" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="splash.gif" />
</ItemGroup>
</Project>

24
PluginLoader/Profile.cs Normal file
View File

@@ -0,0 +1,24 @@
namespace PluginLoader;
public class Profile
{
public Profile()
{
}
public Profile(string name, string[] plugins)
{
Key = Guid.NewGuid().ToString();
Name = name;
Plugins = plugins;
}
// Unique key of the profile
public string Key { get; set; }
// Name of the profile
public string Name { get; set; }
// Plugin IDs
public string[] Plugins { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace PluginLoader.SEPM;
public class Logger
{
public void Log(string text)
{
}
}

View File

@@ -0,0 +1,9 @@
using HarmonyLib;
using VRage.Plugins;
namespace PluginLoader.SEPM;
public interface SEPMPlugin : IPlugin
{
void Main(Harmony harmony, Logger log);
}

View File

@@ -0,0 +1,14 @@
// ReSharper disable UnusedAutoPropertyAccessor.Global
namespace PluginLoader.Stats.Model;
// Request data received from the Plugin Loader to store user consent or withdrawal,
// this request is NOT sent if the user does not give consent in the first place
public class ConsentRequest
{
// Hash of the player's Steam ID
public string PlayerHash { get; set; }
// True if the consent has just given, false if has just withdrawn
public bool Consent { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace PluginLoader.Stats.Model;
// Statistics for a single plugin
public class PluginStat
{
// Number of players who successfully started SE with this plugin enabled anytime during the past 30 days
public int Players { get; set; }
// Total number of upvotes and downvotes since the beginning (votes do not expire)
public int Upvotes { get; set; }
public int Downvotes { get; set; }
// Whether the requesting player tried the plugin
public bool Tried { get; set; }
// Current vote of the requesting player
// +1: Upvoted
// 0: No vote (or cleared it)
// -1: Downvoted
public int Vote { get; set; }
// Number of half stars [1-10] based on the upvote ratio, zero if there are not enough votes on the plugin yet
public int Rating { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace PluginLoader.Stats.Model;
// Statistics for all plugins
public class PluginStats
{
// Key: pluginId
public Dictionary<string, PluginStat> Stats { get; set; } = new();
// Token the player is required to present for voting (making it harder to spoof votes)
public string VotingToken { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace PluginLoader.Stats.Model;
// Request data sent to the StatsServer each time the game is started
public class TrackRequest
{
// Hash of the player's Steam ID
// Hexdump of the first 80 bits of SHA1($"{steamId}")
// The client determines the ID of the player, never the server.
// Using a hash is required for data protection and privacy.
// Using a hash makes it impractical to track back usage or votes to
// individual players, while still allowing for near-perfect deduplication.
// It also prevents stealing all the Steam IDs from the server's database.
public string PlayerHash { get; set; }
// Ids of enabled plugins when the game started
public string[] EnabledPluginIds { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace PluginLoader.Stats.Model;
// Request data sent to the StatsServer each time the user changes his/her vote on a plugin
public class VoteRequest
{
// Id of the plugin
public string PluginId { get; set; }
// Obfuscated player identifier, see Track.PlayerHash
public string PlayerHash { get; set; }
// Voting token returned with the plugin stats
public string VotingToken { get; set; }
// Vote to store
// +1: Upvote
// 0: Clear vote
// -1: Downvote
public int Vote { get; set; }
}

View File

@@ -0,0 +1,101 @@
using PluginLoader.GUI;
using PluginLoader.Stats.Model;
using PluginLoader.Tools;
namespace PluginLoader.Stats;
public static class StatsClient
{
// API address
private static string baseUri = "https://pluginstats.ferenczi.eu";
private static string playerHash;
// Latest voting token received
private static string votingToken;
// API endpoints
private static string ConsentUri => $"{baseUri}/Consent";
private static string StatsUri => $"{baseUri}/Stats";
private static string TrackUri => $"{baseUri}/Track";
private static string VoteUri => $"{baseUri}/Vote";
// Hashed Steam ID of the player
private static string PlayerHash =>
playerHash ??= Tools.Tools.Sha1HexDigest($"{Tools.Tools.GetSteamId()}").Substring(0, 20);
public static void OverrideBaseUrl(string uri)
{
if (string.IsNullOrEmpty(uri))
return;
baseUri = uri;
}
public static bool Consent(bool consent)
{
if (consent)
LogFile.WriteLine("Registering player consent on the statistics server");
else
LogFile.WriteLine("Withdrawing player consent, removing user data from the statistics server");
var consentRequest = new ConsentRequest
{
PlayerHash = PlayerHash,
Consent = consent
};
return SimpleHttpClient.Post(ConsentUri, consentRequest);
}
// This function may be called from another thread.
public static PluginStats DownloadStats()
{
if (!PlayerConsent.ConsentGiven)
{
LogFile.WriteGameLog("Downloading plugin statistics anonymously...");
votingToken = null;
return SimpleHttpClient.Get<PluginStats>(StatsUri);
}
LogFile.WriteGameLog("Downloading plugin statistics, ratings and votes for " + PlayerHash);
var parameters = new Dictionary<string, string> { ["playerHash"] = PlayerHash };
var pluginStats = SimpleHttpClient.Get<PluginStats>(StatsUri, parameters);
votingToken = pluginStats?.VotingToken;
return pluginStats;
}
public static bool Track(string[] pluginIds)
{
var trackRequest = new TrackRequest
{
PlayerHash = PlayerHash,
EnabledPluginIds = pluginIds
};
return SimpleHttpClient.Post(TrackUri, trackRequest);
}
public static PluginStat Vote(string pluginId, int vote)
{
if (votingToken == null)
{
LogFile.WriteLine("Voting token is not available, cannot vote");
return null;
}
LogFile.WriteLine($"Voting {vote} on plugin {pluginId}");
var voteRequest = new VoteRequest
{
PlayerHash = PlayerHash,
PluginId = pluginId,
VotingToken = votingToken,
Vote = vote
};
var stat = SimpleHttpClient.Post<PluginStat, VoteRequest>(VoteUri, voteRequest);
return stat;
}
}

83
PluginLoader/SteamAPI.cs Normal file
View File

@@ -0,0 +1,83 @@
using System.Reflection;
using System.Text;
using HarmonyLib;
using Sandbox.Engine.Networking;
using Steamworks;
using VRage.Game;
using VRage.Utils;
using Parallel = ParallelTasks.Parallel;
namespace PluginLoader;
public static class SteamAPI
{
private static MethodInfo DownloadModsBlocking;
public static bool IsSubscribed(ulong id)
{
var state = (EItemState)SteamUGC.GetItemState(new(id));
return (state & EItemState.k_EItemStateSubscribed) == EItemState.k_EItemStateSubscribed;
}
public static void SubscribeToItem(ulong id)
{
SteamUGC.SubscribeItem(new(id));
}
public static void Update(IEnumerable<ulong> ids)
{
if (!ids.Any())
return;
var modItems =
new List<MyObjectBuilder_Checkpoint.ModItem>(
ids.Select(x => new MyObjectBuilder_Checkpoint.ModItem(x, "Steam")));
LogFile.WriteLine($"Updating {modItems.Count} workshop items");
// Source: MyWorkshop.DownloadWorldModsBlocking
var result = new MyWorkshop.ResultData();
var task = Parallel.Start(delegate { result = UpdateInternal(modItems); });
while (!task.IsComplete)
{
MyGameService.Update();
Thread.Sleep(10);
}
if (!result.Success)
{
var exceptions = task.Exceptions;
if (exceptions != null && exceptions.Length > 0)
{
var sb = new StringBuilder();
sb.AppendLine("An error occurred while updating workshop items:");
foreach (var e in exceptions)
sb.Append(e);
LogFile.WriteLine(sb.ToString());
}
else
{
LogFile.WriteLine("Unable to update workshop items");
}
}
}
public static MyWorkshop.ResultData UpdateInternal(List<MyObjectBuilder_Checkpoint.ModItem> mods)
{
// Source: MyWorkshop.DownloadWorldModsBlockingInternal
MyLog.Default.IncreaseIndent();
var list = new List<WorkshopId>(mods.Select(x => new WorkshopId(x.PublishedFileId, x.PublishedServiceName)));
if (DownloadModsBlocking == null)
DownloadModsBlocking = AccessTools.Method(typeof(MyWorkshop), "DownloadModsBlocking");
var resultData = (MyWorkshop.ResultData)DownloadModsBlocking.Invoke(mods, new object[]
{
mods, new MyWorkshop.ResultData { Success = true }, list, new MyWorkshop.CancelToken()
});
MyLog.Default.DecreaseIndent();
return resultData;
}
}

View File

@@ -0,0 +1,25 @@
using System.Net;
namespace PluginLoader.Tools;
public class PostHttpContent : HttpContent
{
private readonly byte[] content;
public PostHttpContent(string content)
{
this.content = content == null ? null : Tools.Utf8.GetBytes(content);
}
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context)
{
if (content != null && content.Length > 0)
await stream.WriteAsync(content, 0, content.Length);
}
protected override bool TryComputeLength(out long length)
{
length = content.Length;
return true;
}
}

View File

@@ -0,0 +1,190 @@
using System.Net;
using System.Text;
using LitJson;
namespace PluginLoader.Tools;
public static class SimpleHttpClient
{
// REST API request timeout in milliseconds
private const int TimeoutMs = 3000;
public static TV Get<TV>(string url)
where TV : class, new()
{
try
{
using var response = (HttpWebResponse)CreateRequest(HttpMethod.Get, url).GetResponse();
using var responseStream = response.GetResponseStream();
if (responseStream == null)
return null;
using var streamReader = new StreamReader(responseStream, Encoding.UTF8);
return JsonMapper.ToObject<TV>(streamReader.ReadToEnd());
}
catch (WebException e)
{
LogFile.WriteGameLog($"REST API request failed: GET {url} [{e.Message}]");
return null;
}
}
public static TV Get<TV>(string url, Dictionary<string, string> parameters)
where TV : class, new()
{
var uriBuilder = new StringBuilder(url);
AppendQueryParameters(uriBuilder, parameters);
var uri = uriBuilder.ToString();
try
{
using var response = (HttpWebResponse)CreateRequest(HttpMethod.Get, uri).GetResponse();
using var responseStream = response.GetResponseStream();
if (responseStream == null)
return null;
using var streamReader = new StreamReader(responseStream, Encoding.UTF8);
return JsonMapper.ToObject<TV>(streamReader.ReadToEnd());
}
catch (WebException e)
{
LogFile.WriteGameLog($"REST API request failed: GET {uri} [{e.Message}]");
return null;
}
}
public static TV Post<TV>(string url)
where TV : class, new()
{
try
{
var request = CreateRequest(HttpMethod.Post, url);
request.ContentLength = 0L;
return PostRequest<TV>(request);
}
catch (WebException e)
{
LogFile.WriteGameLog($"REST API request failed: POST {url} [{e.Message}]");
return null;
}
}
public static TV Post<TV>(string url, Dictionary<string, string> parameters)
where TV : class, new()
{
var uriBuilder = new StringBuilder(url);
AppendQueryParameters(uriBuilder, parameters);
var uri = uriBuilder.ToString();
try
{
var request = CreateRequest(HttpMethod.Post, uri);
request.ContentType = "application/x-www-form-urlencoded";
request.ContentLength = 0;
return PostRequest<TV>(request);
}
catch (WebException e)
{
LogFile.WriteGameLog($"REST API request failed: POST {uri} [{e.Message}]");
return null;
}
}
public static TV Post<TV, TR>(string url, TR body)
where TR : class, new()
where TV : class, new()
{
try
{
var request = CreateRequest(HttpMethod.Post, url);
var requestJson = JsonMapper.ToJson(body);
var requestBytes = Encoding.UTF8.GetBytes(requestJson);
request.ContentType = "application/json";
request.ContentLength = requestBytes.Length;
return PostRequest<TV>(request, requestBytes);
}
catch (WebException e)
{
LogFile.WriteGameLog($"REST API request failed: POST {url} [{e.Message}]");
return null;
}
}
public static bool Post<TR>(string url, TR body)
where TR : class, new()
{
try
{
var request = CreateRequest(HttpMethod.Post, url);
var requestJson = JsonMapper.ToJson(body);
var requestBytes = Encoding.UTF8.GetBytes(requestJson);
request.ContentType = "application/json";
request.ContentLength = requestBytes.Length;
return PostRequest(request, requestBytes);
}
catch (WebException e)
{
LogFile.WriteGameLog($"REST API request failed: POST {url} [{e.Message}]");
return false;
}
}
private static TV PostRequest<TV>(HttpWebRequest request, byte[] body = null) where TV : class, new()
{
if (body != null)
{
using var requestStream = request.GetRequestStream();
requestStream.Write(body, 0, body.Length);
requestStream.Close();
}
using var response = (HttpWebResponse)request.GetResponse();
using var responseStream = response.GetResponseStream();
if (responseStream == null)
return null;
using var streamReader = new StreamReader(responseStream, Encoding.UTF8);
var data = JsonMapper.ToObject<TV>(streamReader.ReadToEnd());
return data;
}
private static bool PostRequest(HttpWebRequest request, byte[] body = null)
{
if (body != null)
{
using var requestStream = request.GetRequestStream();
requestStream.Write(body, 0, body.Length);
requestStream.Close();
}
using var response = (HttpWebResponse)request.GetResponse();
return response.StatusCode == HttpStatusCode.OK;
}
private static HttpWebRequest CreateRequest(HttpMethod method, string url)
{
var http = WebRequest.CreateHttp(url);
http.Method = method.ToString().ToUpper();
http.Timeout = TimeoutMs;
return http;
}
private static void AppendQueryParameters(StringBuilder stringBuilder, Dictionary<string, string> parameters)
{
if (parameters == null || parameters.Count == 0)
return;
var first = true;
foreach (var p in parameters)
{
stringBuilder.Append(first ? '?' : '&');
first = false;
stringBuilder.Append(Uri.EscapeDataString(p.Key));
stringBuilder.Append('=');
stringBuilder.Append(Uri.EscapeDataString(p.Value));
}
}
}

View File

@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using Steamworks;
namespace PluginLoader.Tools;
public static class Tools
{
public static readonly UTF8Encoding Utf8 = new();
public static string Sha1HexDigest(string text)
{
using var sha1 = new SHA1Managed();
var buffer = Utf8.GetBytes(text);
var digest = sha1.ComputeHash(buffer);
return BytesToHex(digest);
}
private static string BytesToHex(IReadOnlyCollection<byte> ba)
{
var hex = new StringBuilder(2 * ba.Count);
foreach (var t in ba)
hex.Append(t.ToString("x2"));
return hex.ToString();
}
public static string FormatDateIso8601(DateTime dt)
{
return dt.ToString("s").Substring(0, 10);
}
public static ulong GetSteamId()
{
return SteamUser.GetSteamID().m_SteamID;
}
// FIXME: Replace this with the proper library call, I could not find one
public static string FormatUriQueryString(Dictionary<string, string> parameters)
{
var query = new StringBuilder();
foreach (var p in parameters)
{
if (query.Length > 0)
query.Append('&');
query.Append($"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}");
}
return query.ToString();
}
}

BIN
PluginLoader/splash.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB