You've already forked UnrealEngineUWP
mirror of
https://github.com/izzy2lost/UnrealEngineUWP.git
synced 2026-03-26 18:15:20 -07:00
#rb Sebastian.Nordgren #rnx #jira UE-152958 #preflight 629717b72c618ddc669143d7 - Instead of just building the UnrealVirtualizationTool we will now check to see if there is a precompiled version we can use. -- If the user has rebuilt the editor locally, then we will ignore any precompiled version of UnrealVirtualizationTool and rebuild that aswell incase there are code changes that it needs to pick up. - To tell if an exe is locally built or a precompiled binary we check for a .version OR .target file (this can vary depending how a program is set up) then look for the JSON property 'IsPromotedBuild ' which when none zero means that the binary is probably prebuilt. -- This isn't very robust and I am not happy with it, but we don't seem to have an official way to determine a precompiled binary. When we do get an offical way to do it I will update the command. [CL 20448604 by paul chipchase in ue5-main branch]
1005 lines
38 KiB
C#
1005 lines
38 KiB
C#
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
|
|
#if DEBUG
|
|
// When enabled the command will not actually submit the changelist, but will act like it did.
|
|
// This is for development purposes only and so is only available in the debug config.
|
|
//#define UE_DEBUG_DISABLE_SUBMITS
|
|
#endif
|
|
|
|
using EpicGames.Core;
|
|
using EpicGames.Perforce;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace P4VUtils.Commands
|
|
{
|
|
/// <summary>
|
|
/// Contains info about a built exe, generated by our build system (found in .version files)
|
|
/// </summary>
|
|
public class BuildVersion
|
|
{
|
|
public int? MajorVersion { get; set; }
|
|
public int? MinorVersion { get; set; }
|
|
public int? PatchVersion { get; set; }
|
|
public int? Changelist { get; set; }
|
|
public int? CompatibleChangelist { get; set; }
|
|
public int? IsLicenseeVersion { get; set; }
|
|
public int? IsPromotedBuild { get; set; }
|
|
public string? BranchName { get; set; }
|
|
public string? BuildId { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A partial representation of the .target file generated by our build system. We are only
|
|
/// interested in the BuildVersion info and so the class does not have any other members.
|
|
/// </summary>
|
|
public class TargetBuildVersion
|
|
{
|
|
public BuildVersion? Version { get; set; }
|
|
}
|
|
|
|
public static class LoggingUtils
|
|
{
|
|
/// <summary>
|
|
/// This extension is used to write a new line to the logger.
|
|
/// log.NewLine better shows intent over log.LogInformation("")
|
|
/// </summary>
|
|
public static void NewLine(this ILogger logger)
|
|
{
|
|
logger.LogInformation("");
|
|
}
|
|
}
|
|
|
|
class SubmitAndVirtualizeCommand : Command
|
|
{
|
|
public override string Description => "Virtualize And Submit";
|
|
|
|
public override CustomToolInfo CustomTool => new CustomToolInfo("Virtualize And Submit", "$c %C") { ShowConsole = true, RefreshUI = true };
|
|
|
|
public override async Task<int> Execute(string[] args, IReadOnlyDictionary<string, string> configValues, ILogger logger)
|
|
{
|
|
// Parse command lines
|
|
if (args.Length < 3)
|
|
{
|
|
logger.LogError("Not enough args for command, tool is now exiting");
|
|
return 1;
|
|
}
|
|
|
|
string clientSpec = args[1];
|
|
|
|
List<int> changelistsToSubmit = new List<int>();
|
|
|
|
for (int index = 2; index < args.Length; ++index)
|
|
{
|
|
if (!int.TryParse(args[index], out int changeNumber))
|
|
{
|
|
logger.LogError("'{Argument}' is not a numbered changelist, tool is now exiting", args[index]);
|
|
return 1;
|
|
}
|
|
|
|
changelistsToSubmit.Add(changeNumber);
|
|
}
|
|
|
|
if (changelistsToSubmit.Count == 0)
|
|
{
|
|
logger.LogError("No changelists to submit were provided, tool is now exiting");
|
|
return 1;
|
|
}
|
|
|
|
// Connect to perforce and validate
|
|
|
|
logger.LogInformation("Connecting to Perforce...");
|
|
|
|
// We prefer the native client to avoid the problem where different versions of p4.exe expect
|
|
// or return records with different formatting to each other.
|
|
PerforceSettings settings = new PerforceSettings(PerforceSettings.Default) { PreferNativeClient = true, ClientName = clientSpec };
|
|
using IPerforceConnection perforceConnection = await PerforceConnection.CreateAsync(settings, logger);
|
|
if (perforceConnection == null)
|
|
{
|
|
logger.LogError("Failed to connect to Perforce, tool is now exiting");
|
|
return 1;
|
|
}
|
|
|
|
for (int index = 0; index < changelistsToSubmit.Count; ++index)
|
|
{
|
|
logger.NewLine();
|
|
logger.LogInformation("========== Processing change list {Index}/{Count} ==========", index + 1, changelistsToSubmit.Count);
|
|
|
|
int changeNumber = changelistsToSubmit[index];
|
|
if(await ProcessChangelist(perforceConnection, changeNumber, logger) == false)
|
|
{
|
|
logger.LogError("Failed to process {Changelist}, tool is now exiting", changeNumber);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
logger.NewLine();
|
|
logger.LogInformation("========== All changelist(s) submitted successfully, tool is now exiting ==========");
|
|
|
|
return 0;
|
|
}
|
|
|
|
public static async Task<bool> ProcessChangelist(IPerforceConnection perforceConnection, int changeNumber, ILogger logger)
|
|
{
|
|
if (perforceConnection == null || perforceConnection.Settings == null || perforceConnection.Settings.ClientName == null)
|
|
{
|
|
logger.LogError("Invalid Perforce connection!");
|
|
return false;
|
|
}
|
|
|
|
string clientSpec = perforceConnection.Settings.ClientName;
|
|
|
|
logger.LogInformation("Attempting to virtualize and submit changelist {Change} in the workspace {Spec}", changeNumber, clientSpec);
|
|
|
|
// First we need to find the packages in the changelist
|
|
string[]? localFilePaths = await FindPackagesInChangelist(perforceConnection, changeNumber, logger);
|
|
if (localFilePaths == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (localFilePaths.Length > 0)
|
|
{
|
|
// If the changelist has shelved files then the submit will fail, so if we have package files that
|
|
// need to be checked for virtualization we don't want to have the potentially long virtualization
|
|
// process run (during which the user will alt-tab) only for it to fail at the end.
|
|
// So we should check for shelved files now and early out before the virtualization process runs.
|
|
if (await DoesChangelistHaveShelvedFiles(perforceConnection, changeNumber) == true)
|
|
{
|
|
logger.LogError("Changelist {Change} has shelved files and cannot be submitted", changeNumber);
|
|
return false;
|
|
}
|
|
|
|
logger.LogInformation("Found {Amount} package(s) that may need virtualization", localFilePaths.Length);
|
|
|
|
// Now sort the package paths by their project (it is unlikely but a user could be submitting content
|
|
// from multiple projects that the same time)
|
|
|
|
Dictionary<string, List<string>> projects = SortPackagesIntoProjects(localFilePaths, logger);
|
|
|
|
logger.LogInformation("The packages are distributed between {Amount} project(s)", projects.Count);
|
|
|
|
// Find engine per project
|
|
IReadOnlyDictionary<string, string> engineInstalls = EnumerateEngineInstallations(logger);
|
|
|
|
foreach (KeyValuePair<string, List<string>> project in projects)
|
|
{
|
|
string engineRoot = GetEngineRootForProject(project.Key, logger);
|
|
if (!String.IsNullOrEmpty(engineRoot))
|
|
{
|
|
logger.NewLine();
|
|
logger.LogInformation("Attempting to virtualize packages in project '{Project}' via the engine installation '{Engine}'", project.Key, engineRoot);
|
|
// @todo Many projects can share the same engine install, and technically UnrealVirtualizationTool
|
|
// supports the virtualization files from many projects at the same time. We could consider doing
|
|
// this pass per engine install rather than per project? At the very least we should only 'build'
|
|
// the tool once per engine
|
|
|
|
Task<bool> compileResult = ValidateOrBuildVirtualizationTool(engineRoot, logger);
|
|
|
|
string tempFilesPath = await WritePackageFileList(project.Value, logger);
|
|
|
|
// Check if the compilation of the tool succeeded or not
|
|
if (await compileResult == false)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Even though this will have been done while we were waiting for BuildVirtualizationTool to complete
|
|
// we want to log that it was done after so that the output log makes sense to the user, otherwise
|
|
// they will end up thinking that they are waiting on the PackageList to be written rather than on
|
|
// the tool to be built.
|
|
logger.LogInformation("PackageList was written to '{Path}'", tempFilesPath);
|
|
|
|
if (await RunVirtualizationTool(engineRoot, clientSpec, tempFilesPath, logger) == false)
|
|
{
|
|
return false;
|
|
};
|
|
|
|
// @todo Make sure this always gets cleaned up when we go out of scope
|
|
File.Delete(tempFilesPath);
|
|
}
|
|
else
|
|
{
|
|
logger.LogError("Failed to find engine root for project {Project}", project.Key);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
logger.NewLine();
|
|
logger.LogInformation("All packages have been virtualized");
|
|
|
|
//@todo ideally we should get the tags back from UnrealVirtualizationTool
|
|
ChangeRecord? changeRecord = await StampChangelistDescription(perforceConnection, changeNumber, logger);
|
|
if (changeRecord == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
logger.LogInformation("Attempting to submit changelist {Number}...", changeNumber);
|
|
|
|
#if !UE_DEBUG_DISABLE_SUBMITS
|
|
if (await SubmitChangelist(perforceConnection, changeNumber, logger) == false)
|
|
{
|
|
// If the final submit failed we remove the virtualization tag, even though the changelist is technically
|
|
// virtualized at this point and submitting it would be safe.
|
|
// This is to keep the behavior the same as the native code paths.
|
|
logger.LogInformation("Removing virtualization tags from the changelist...");
|
|
PerforceResponse updateResponse = await perforceConnection.TryUpdateChangeAsync(UpdateChangeOptions.None, changeRecord, CancellationToken.None);
|
|
if (!updateResponse.Succeeded)
|
|
{
|
|
logger.LogError("Failed to remove the virtualization tags!");
|
|
}
|
|
logger.LogInformation("Virtualization tags have been removed.");
|
|
|
|
return false;
|
|
}
|
|
#else
|
|
// Dummy pretend submit for debugging purposes, allows developer iteration without actually submitting
|
|
logger.LogInformation("Successfully submited changelist {Change}", changeNumber);
|
|
#endif // !UE_DEBUG_DISABLE_SUBMITS
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("The changelist does not contain any package files, submitting as normal...");
|
|
|
|
if (await SubmitChangelist(perforceConnection, changeNumber, logger) == false)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks to see if a precompiled version of the UnrealVirtualizationTool exists and if so
|
|
/// if it is valid for us to use this.
|
|
/// If we cannot use a precompiled version of the tool then we will request that it be
|
|
/// compiled locally.
|
|
/// </summary>
|
|
/// <param name="engineRoot">Root path of the engine we want to build the tool for</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>True if the UnrealVirtualizationTool.exe exists in a form that we can use, otherwise false</returns>
|
|
private static async Task<bool> ValidateOrBuildVirtualizationTool(string engineRoot, ILogger logger)
|
|
{
|
|
// Check to see if the precompiled binaries for the editor and virtualization tool are available
|
|
bool hasEditorPCB = IsPrecompiledVersionAvailable(engineRoot, "UnrealEditor", logger);
|
|
bool hasToolPCB = IsPrecompiledVersionAvailable(engineRoot, "UnrealVirtualizationTool", logger);
|
|
|
|
// If the editor is not a precompiled binary, then we know that the user has compiled it locally.
|
|
// This means that even if we have a precompiled version of UnrealVirtualizationTool we cannot
|
|
// use it as it's virtualization process might be 'out of date' compared to the locally compiled
|
|
// editor.
|
|
// This means we can only use the precompiled binary for UnrealVirtualizationTool if UnrealEditor
|
|
// is also a precompiled binary.
|
|
if (hasEditorPCB && hasToolPCB)
|
|
{
|
|
logger.LogInformation("Using a precompiled binary version of UnrealVirtualizationTool");
|
|
return true;
|
|
}
|
|
else if (hasToolPCB)
|
|
{
|
|
// Let the user know why we are compiling UnrealVirtualizationTool even though there is a precompiled
|
|
// version available.
|
|
logger.LogInformation("Cannot use the precompiled version of UnrealVirtualizationTool as the UnrealEditor has been locally compiled");
|
|
}
|
|
|
|
// We must try to build the tool locally, if the user does not have a valid code compilation
|
|
// tool chain installed then UnrealBuildTool will give them errors.
|
|
return await BuildVirtualizationTool(engineRoot, logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if there is a precompiled version of a given program available for use.
|
|
/// </summary>
|
|
/// <param name="engineRoot">Root path of the engine we want to build the tool for</param>
|
|
/// <param name="programName">Name of the program to check for</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>True if there is a version of the program that was precompiled, otherwise false</returns>
|
|
private static bool IsPrecompiledVersionAvailable(string engineRoot, string programName, ILogger logger)
|
|
{
|
|
// Depending on how a program is set up it might have a .version file or a .target file that contains info
|
|
// about the exe itself. From this we can find a JSON property "IsPromotedBuild" which when none zero
|
|
// means that the build was precompiled.
|
|
// This isn't the most robust check but should work for now until we can add an 'offical' codified way to
|
|
// check for precompiled binaries.
|
|
|
|
// First we check for a .version file for the given program
|
|
string versionPath = String.Format(@"{0}\Engine\Binaries\Win64\{1}.version", engineRoot, programName);
|
|
|
|
if (System.IO.File.Exists(versionPath))
|
|
{
|
|
using (StreamReader inputStream = new StreamReader(versionPath))
|
|
{
|
|
string jsonObject = inputStream.ReadToEnd();
|
|
|
|
BuildVersion? buildVersion = null;
|
|
try
|
|
{
|
|
buildVersion = JsonSerializer.Deserialize<BuildVersion>(jsonObject);
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
// No need to print the exception as the user will not be expected to fix the file, a warning will be shown below
|
|
}
|
|
|
|
if (buildVersion == null || buildVersion.IsPromotedBuild == null)
|
|
{
|
|
logger.LogWarning("Unable to read build version from '{FilePath}'", versionPath);
|
|
}
|
|
else if (buildVersion.IsPromotedBuild != 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// We failed to find what we needed from the .version file, so try the .target file instead
|
|
string targetPath = String.Format(@"{0}\Engine\Binaries\Win64\{1}.target", engineRoot, programName);
|
|
|
|
if (System.IO.File.Exists(targetPath))
|
|
{
|
|
using (StreamReader inputStream = new StreamReader(targetPath))
|
|
{
|
|
string jsonObject = inputStream.ReadToEnd();
|
|
|
|
TargetBuildVersion? targetBuildVersion = null;
|
|
try
|
|
{
|
|
targetBuildVersion = JsonSerializer.Deserialize<TargetBuildVersion>(jsonObject);
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
// No need to print the exception as the user will not be expected to fix the file, a warning will be shown below
|
|
}
|
|
|
|
if (targetBuildVersion == null || targetBuildVersion.Version == null || targetBuildVersion.Version.IsPromotedBuild == null)
|
|
{
|
|
logger.LogWarning("Unable to read build version from '{FilePath}'", targetPath);
|
|
}
|
|
else if (targetBuildVersion.Version.IsPromotedBuild != 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compiles UnrealVirtualizationTool via RunUBT.bat
|
|
/// </summary>
|
|
/// <param name="engineRoot">Root path of the engine we want to build the tool for</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>True if the tool built successfully, otherwise false</returns>
|
|
private static async Task<bool> BuildVirtualizationTool(string engineRoot, ILogger logger)
|
|
{
|
|
logger.LogInformation("Building UnrealVirtualizationTool...");
|
|
|
|
string buildBatchFile = Path.Combine(engineRoot, @"Engine\Build\BatchFiles\RunUBT.bat");
|
|
StringBuilder arguments = new StringBuilder($"{buildBatchFile.QuoteArgument()}");
|
|
arguments.Append(" UnrealVirtualizationTool Win64 development -Progress");
|
|
|
|
string shellFileName = Environment.GetEnvironmentVariable("COMSPEC") ?? "C:\\Windows\\System32\\cmd.exe";
|
|
string shellArguments = $"/d/s/c \"{arguments}\"";
|
|
|
|
using (MemoryStream bufferedOutput = new MemoryStream())
|
|
using (ManagedProcessGroup Group = new ManagedProcessGroup())
|
|
using (ManagedProcess Process = new ManagedProcess(Group, shellFileName, shellArguments, null, null, System.Diagnostics.ProcessPriorityClass.Normal))
|
|
{
|
|
await Process.CopyToAsync(bufferedOutput, CancellationToken.None);
|
|
|
|
// We only show the output if there was an error to avoid showing too much
|
|
// info to the user when they are invoking this from p4v.
|
|
// We need to print everything from stdout and stderr as stderr alone often
|
|
// does not contain all of the build failure info. (note that ManagedProcess is
|
|
// merging the two streams by default)
|
|
if (Process.ExitCode != 0)
|
|
{
|
|
bufferedOutput.Seek(0, SeekOrigin.Begin);
|
|
|
|
using (Stream stdOutput = Console.OpenStandardOutput())
|
|
{
|
|
await bufferedOutput.CopyToAsync(stdOutput, CancellationToken.None);
|
|
}
|
|
|
|
logger.LogError("Failed to build UnrealVirtualizationTool");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
logger.LogInformation("UnrealVirtualizationTool built successfully");
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the UnrealVirtualizationTool
|
|
/// </summary>
|
|
/// <param name="engineRoot">Root path of the engine we want to run the tool from</param>
|
|
/// <param name="clientSpec">The perforce client spec that the files are in</param>
|
|
/// <param name="packageListPath">A path to a text file containing the paths of the packages to be virtualized</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns></returns>
|
|
private static async Task<bool> RunVirtualizationTool(string engineRoot, string clientSpec, string packageListPath, ILogger logger)
|
|
{
|
|
logger.LogInformation("Running UnrealVirtualizationTool...");
|
|
|
|
string toolPath = Path.Combine(engineRoot, @"Engine\Binaries\Win64\UnrealVirtualizationTool.exe");
|
|
string toolArgs = string.Format("-ClientSpecName={0} -Mode=PackageList -Path={1}", clientSpec, packageListPath);
|
|
|
|
using (Stream stdOutput = Console.OpenStandardOutput())
|
|
using (ManagedProcessGroup Group = new ManagedProcessGroup())
|
|
using (ManagedProcess Process = new ManagedProcess(Group, toolPath, toolArgs, null, null, System.Diagnostics.ProcessPriorityClass.Normal))
|
|
{
|
|
await Process.CopyToAsync(stdOutput, CancellationToken.None);
|
|
|
|
if (Process.ExitCode != 0)
|
|
{
|
|
logger.LogError("UnrealVirtualizationTool failed!");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes out a list of package paths to a text file stored in the users
|
|
/// temp directory.
|
|
/// The name of the file will be a randomly generated GUID making file name
|
|
/// collisions unlikely.
|
|
/// Each path will be written to it's own line inside of the files
|
|
/// </summary>
|
|
/// <param name="packagePaths">A list of package file paths to be written to the file</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>The path of the file once it has been written</returns>
|
|
private static async Task<string> WritePackageFileList(List<string> packagePaths, ILogger logger)
|
|
{
|
|
// We pass the list of packages to the tool via a file as the number of package
|
|
// paths can potentially be huge and exceed the cmdline length.
|
|
// So currently we write the files to a UnrealVirtualizationTool directory under the temp directory
|
|
string tempDirectory = Path.Combine(Path.GetTempPath(), "UnrealVirtualizationTool");
|
|
Directory.CreateDirectory(tempDirectory);
|
|
|
|
string tempFilesPath = Path.Combine(tempDirectory, Guid.NewGuid().ToString() + ".txt");
|
|
|
|
using (StreamWriter Writer = new StreamWriter(tempFilesPath))
|
|
{
|
|
foreach (string line in packagePaths)
|
|
{
|
|
await Writer.WriteLineAsync(line);
|
|
}
|
|
}
|
|
|
|
return NormalizeFilename(tempFilesPath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sorts the given package paths by the unreal project that they are found to be in.
|
|
/// If a package is found to not be in a project it will currently raise a warning
|
|
/// but not prevent further execution.
|
|
/// </summary>
|
|
/// <param name="packagePaths">A list of absolute file paths pointing to package files</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>A dictionary where the key is the path of an unreal project and the value is a list of packages in that project</returns>
|
|
private static Dictionary<string, List<string>> SortPackagesIntoProjects(string[] packagePaths, ILogger logger)
|
|
{
|
|
Dictionary<string, List<string>> projects = new Dictionary<string, List<string>>();
|
|
foreach (string path in packagePaths)
|
|
{
|
|
string normalizedPath = NormalizeFilename(path);
|
|
string projectFilePath = FindProjectForPackage(normalizedPath, logger);
|
|
|
|
if (!String.IsNullOrEmpty(projectFilePath))
|
|
{
|
|
if (!projects.ContainsKey(projectFilePath))
|
|
{
|
|
projects.Add(projectFilePath, new List<string>());
|
|
}
|
|
|
|
projects[projectFilePath].Add(normalizedPath);
|
|
}
|
|
else
|
|
{
|
|
// @todo Re-evaluate if we want this warning at some point in the future.
|
|
// Technically submitting a package file not under a project could have valid use cases
|
|
// but if the user called this command it would indicate that they expect that they are
|
|
// submitting packages in a valid project.
|
|
// So for now we give a warning so that it is easier to catch cases where packages are
|
|
// thought not to be part of a project even if they are.
|
|
logger.LogWarning("Unable to find a valid project for the package '{Path}'", normalizedPath);
|
|
}
|
|
}
|
|
|
|
return projects;
|
|
}
|
|
|
|
private static async Task<bool> SubmitChangelist(IPerforceConnection perforceConnection, int changeNumber, ILogger logger)
|
|
{
|
|
PerforceResponseList<SubmitRecord> submitResponses = await TrySubmitAsync(perforceConnection, changeNumber, SubmitOptions.None, CancellationToken.None);
|
|
|
|
bool successfulSubmit = submitResponses.All(x => x.Succeeded);
|
|
if (successfulSubmit)
|
|
{
|
|
// @todo The submit API really should return the number that the changelist was finally submit as so that
|
|
// we can log that here instead.
|
|
logger.LogInformation("Successfully submited changelist {Change}", changeNumber);
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
// Log every response that was a failure and has an error message associated with it
|
|
logger.LogError("ERROR - Submit failed due to:");
|
|
foreach (PerforceResponse<SubmitRecord> response in submitResponses)
|
|
{
|
|
if (response.Failed && response.Error != null)
|
|
{
|
|
logger.LogError("\t{Message}", response.Error.Data.ToString());
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the virtualization tags to a changelist description which is used to show that the virtualization
|
|
/// process has been run on it, before it was submitted.
|
|
/// </summary>
|
|
/// <param name="perforceConnection">A valid connection to perforce to use</param>
|
|
/// <param name="changeNumber">The changelist we should be stamping</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>If the stamp succeeded then it returns a ChangeRecord representing the changelist as it was before it was stamped, returns null on failure</returns>
|
|
private static async Task<ChangeRecord?> StampChangelistDescription(IPerforceConnection perforceConnection, int changeNumber, ILogger logger)
|
|
{
|
|
logger.LogInformation("Adding virtualization tags to changelist description...");
|
|
|
|
ChangeRecord changeRecord;
|
|
try
|
|
{
|
|
changeRecord = await perforceConnection.GetChangeAsync(GetChangeOptions.None, changeNumber);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
logger.LogError("Failed to get the description {Change} so we can edit it", changeNumber);
|
|
return null;
|
|
}
|
|
|
|
string? originalDescription = changeRecord.Description;
|
|
|
|
// @todo Should be getting the tag from the virtualization tool!
|
|
changeRecord.Description += "\n#virtualized\n";
|
|
|
|
PerforceResponse updateResponse = await perforceConnection.TryUpdateChangeAsync(UpdateChangeOptions.None, changeRecord, CancellationToken.None);
|
|
if (updateResponse.Succeeded)
|
|
{
|
|
// Restore the original description so we are returning the original ChangeRecord
|
|
changeRecord.Description = originalDescription;
|
|
return changeRecord;
|
|
}
|
|
else
|
|
{
|
|
logger.LogError("Failed to edit the description of {Change} due to\n{Message}", changeNumber, updateResponse.Error!.ToString());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Find all of the unreal packages in a single perforce changelist and return them as local file paths on the users machine.
|
|
/// </summary>
|
|
/// <param name="perforceConnection">A valid connection to perforce. This should have the correct client spec for the given changelist number</param>
|
|
/// <param name="changeNumber">The changelist we should look in</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>A list of all the packages in the given changelist, in local file path format</returns>
|
|
private static async Task<string[]?> FindPackagesInChangelist(IPerforceConnection perforceConnection, int changeNumber, ILogger logger)
|
|
{
|
|
logger.LogInformation("Finding files in changelist {Change}...", changeNumber);
|
|
DescribeRecord changeRecord;
|
|
try
|
|
{
|
|
changeRecord = await perforceConnection.DescribeAsync(changeNumber, CancellationToken.None);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
logger.LogError("Failed to find changelist {Change}", changeNumber);
|
|
return null;
|
|
}
|
|
|
|
if (changeRecord.Files.Count == 0)
|
|
{
|
|
logger.LogError("Changelist {Change} is empty, cannot submit", changeNumber);
|
|
return null;
|
|
}
|
|
|
|
// @todo Should we check if the changelist has shelved files and error at this point since
|
|
// we know that the user will not be able to submit it?
|
|
|
|
// Find the depth paths in the changelist that point to package files
|
|
string[] depotPackagePaths = changeRecord.Files.Select(x => x.DepotFile).Where(x => IsPackagePath(x)).ToArray();
|
|
|
|
// We can early out if there are no packages in the changelist
|
|
if (depotPackagePaths.Length == 0)
|
|
{
|
|
return depotPackagePaths;
|
|
}
|
|
|
|
// Now convert from depot paths to local paths on the users machine
|
|
List<WhereRecord> whereRecords = await perforceConnection.WhereAsync(depotPackagePaths, CancellationToken.None).ToListAsync();
|
|
|
|
return whereRecords.Select(x => x.Path).ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns if a changelist has shelved files or not
|
|
/// </summary>
|
|
/// <param name="perforceConnection">A valid connection to perforce. This should have the correct client spec for the given changelist number</param>
|
|
/// <param name="changeNumber">The changelist we should look in</param>
|
|
/// <returns>True if the changelist contains shelved files, otherwise false</returns>
|
|
private static async Task<bool> DoesChangelistHaveShelvedFiles(IPerforceConnection perforceConnection, int changeNumber)
|
|
{
|
|
List<DescribeRecord> responses = await perforceConnection.DescribeAsync(DescribeOptions.Shelved, -1, new int[] { changeNumber }, CancellationToken.None);
|
|
|
|
if (responses.Count == 1)
|
|
{
|
|
return responses[0].Files.Count != 0;
|
|
}
|
|
else
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the unreal project file for a given unreal package
|
|
/// </summary>
|
|
/// <param name="packagePath">The package to find the project for</param>
|
|
/// <param name="logger">Interface for logging</param>
|
|
/// <returns>The path of the projects .uproject file if found, an empty string if no valid project file was found</returns>
|
|
private static string FindProjectForPackage(string packagePath, ILogger logger)
|
|
{
|
|
// @todo note that this function mirrors FUnrealVirtualizationToolApp::TryFindProject
|
|
// both will not work correctly with some plugin setups, this is known and will be
|
|
// fixed later when FUnrealVirtualizationToolApp is also fixed.
|
|
packagePath = NormalizeFilename(packagePath);
|
|
|
|
int contentIndex = packagePath.LastIndexOf("/content/", StringComparison.OrdinalIgnoreCase);
|
|
if (contentIndex == -1)
|
|
{
|
|
logger.LogWarning("'{Path}' is not under a content directory", packagePath);
|
|
return string.Empty;
|
|
}
|
|
|
|
while (contentIndex != -1)
|
|
{
|
|
// Assume that the project directory is the parent of the /content/ directory
|
|
string projectDirectory = packagePath[..contentIndex];
|
|
|
|
string[] projectFiles = Directory.GetFiles(projectDirectory, "*.uproject");
|
|
|
|
if (projectFiles.Length == 0)
|
|
{
|
|
// If there was no project file, the package could be in a plugin, so lets check for that
|
|
string pluginDirectory = projectDirectory;
|
|
string[] pluginFiles = Directory.GetFiles(pluginDirectory, "*.uplugin");
|
|
|
|
if (pluginFiles.Length == 1)
|
|
{
|
|
// We have a valid plugin file, so we should be able to find a /plugins/ directory which will be just below the project directory
|
|
int pluginIndex = pluginDirectory.LastIndexOf("/plugins/", StringComparison.OrdinalIgnoreCase);
|
|
if (pluginIndex != -1)
|
|
{
|
|
// We found the plugin root directory so the one above it should be the project directory
|
|
projectDirectory = pluginDirectory[..pluginIndex];
|
|
projectFiles = Directory.GetFiles(projectDirectory, "*.uproject");
|
|
}
|
|
}
|
|
else if (pluginFiles.Length > 1)
|
|
{
|
|
logger.LogWarning("Found multiple .uplugin files for '{Path}' at '{PluginDir}'", pluginDirectory);
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
if (projectFiles.Length == 1)
|
|
{
|
|
return NormalizeFilename(projectFiles[0]);
|
|
}
|
|
else if (projectFiles.Length > 1)
|
|
{
|
|
logger.LogWarning("Found multiple .uproject files for '{Path}' at '{ProjectDir}'", projectDirectory);
|
|
return string.Empty;
|
|
}
|
|
|
|
// Could be more than one content directory in the path so lets keep looking
|
|
contentIndex = packagePath.LastIndexOf("/content/", contentIndex, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// We found one or more content directories but none of them contained a project file
|
|
logger.LogWarning("FFailed to find project file for '{Package}'", packagePath);
|
|
return string.Empty;
|
|
}
|
|
|
|
private static string GetEngineRootForProject(string projectFilePath, ILogger logger)
|
|
{
|
|
string engineIdentifier = GetEngineIdentifierForProject(projectFilePath, logger);
|
|
if (!String.IsNullOrEmpty(engineIdentifier))
|
|
{
|
|
string engineRoot = GetEngineRootDirFromIdentifier(engineIdentifier, logger);
|
|
if (!String.IsNullOrEmpty(engineIdentifier))
|
|
{
|
|
return engineRoot;
|
|
}
|
|
else
|
|
{
|
|
logger.LogWarning("Unable to find an engine root for installation {Identifier}, will attempt to find the engine via the directory hierarchy", engineIdentifier);
|
|
}
|
|
}
|
|
|
|
return FindEngineFromPath(projectFilePath);
|
|
}
|
|
|
|
// The following functions mirror code found @Engine\Source\Runtime\CoreUObject\Private\Misc\PackageName.cpp
|
|
#region CoreUObject PackageName
|
|
|
|
private static bool IsPackagePath(string path)
|
|
{
|
|
return IsBinaryPackagePath(path) || IsTextPackagePath(path);
|
|
}
|
|
|
|
private static bool IsBinaryPackagePath(string path)
|
|
{
|
|
return path.EndsWith(".uasset", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".umap", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsTextPackagePath(string path)
|
|
{
|
|
return path.EndsWith(".utxt", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".utxtmap", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
#endregion
|
|
|
|
// The following functions mirror code found @Engine\Source\Runtime\Core\Private\Misc\Paths.cpp
|
|
#region Paths
|
|
|
|
private static string NormalizeFilename(string path)
|
|
{
|
|
return path.Replace('\\', '/');
|
|
}
|
|
|
|
private static string NormalizeDirectoryName(string path)
|
|
{
|
|
path = path.Replace('\\', '/');
|
|
|
|
if (path.EndsWith('/'))
|
|
{
|
|
path = path.Remove(path.Length - 1, 1);
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
#endregion
|
|
|
|
// The following functions mirror code found @Engine\Source\Developer\DesktopPlatform\Private\DesktopPlatformBase.cpp
|
|
#region DesktopPlatform DesktopPlatformBase
|
|
|
|
// Note that for the C# versions we only support the modern way of associating
|
|
// projects with engine installation, via the 'EngineAssociation' entry in
|
|
// the .uproject file.
|
|
|
|
private static string GetEngineIdentifierForProject(string projectFilePath, ILogger logger)
|
|
{
|
|
try
|
|
{
|
|
JsonObject root = JsonObject.Read(new FileReference(projectFilePath));
|
|
string engineIdentifier = root.GetStringField("EngineAssociation");
|
|
if (!String.IsNullOrEmpty(engineIdentifier))
|
|
{
|
|
// @todo what if it is a path? (native code has support for this
|
|
// possibly for an older version)
|
|
return engineIdentifier;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError("Failed to parse {File} to find the engine association due to: {Reason}", projectFilePath, ex.Message);
|
|
}
|
|
|
|
// @todo In the native version if there is no identifier we will try to
|
|
// find the engine root in the directory hierarchy then either find it
|
|
// identifier or register one if needed.
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
private static string FindEngineFromPath(string path)
|
|
{
|
|
string? directoryName = Path.GetDirectoryName(path);
|
|
|
|
if (!String.IsNullOrEmpty(directoryName))
|
|
{
|
|
DirectoryInfo? directoryToSearch = new DirectoryInfo(directoryName);
|
|
|
|
while (directoryToSearch != null)
|
|
{
|
|
if (IsValidRootDirectory(directoryToSearch.ToString()))
|
|
{
|
|
return NormalizeDirectoryName(directoryToSearch.ToString());
|
|
}
|
|
|
|
directoryToSearch = Directory.GetParent(directoryToSearch.ToString());
|
|
}
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
private static string GetEngineRootDirFromIdentifier(string engineIdentifier, ILogger logger)
|
|
{
|
|
IReadOnlyDictionary<string, string> engineInstalls = EnumerateEngineInstallations(logger);
|
|
|
|
if (engineInstalls.TryGetValue(engineIdentifier, out string? engineRoot))
|
|
{
|
|
return engineRoot;
|
|
}
|
|
else
|
|
{
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
private static string GetEngineIdentifierFromRootDir(string rootDirectory, ILogger logger)
|
|
{
|
|
rootDirectory = NormalizeDirectoryName(rootDirectory);
|
|
|
|
IReadOnlyDictionary<string, string> engineInstalls = EnumerateEngineInstallations(logger);
|
|
|
|
foreach (KeyValuePair<string, string> pair in engineInstalls)
|
|
{
|
|
if (String.Equals(pair.Value,rootDirectory, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return pair.Key;
|
|
}
|
|
}
|
|
|
|
return String.Empty;
|
|
}
|
|
|
|
private static bool IsValidRootDirectory(string rootDirectory)
|
|
{
|
|
// Check that there's an Engine\Binaries directory underneath the root
|
|
string engineBinariesDirectory = Path.Combine(rootDirectory, "Engine/Binaries");
|
|
|
|
if (!Directory.Exists(engineBinariesDirectory))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Also check there's an Engine\Build directory. This will filter out anything
|
|
// //that has an engine-like directory structure but doesn't allow building
|
|
// code projects - like the launcher.
|
|
|
|
string engineBuildDirectory = Path.Combine(rootDirectory, "Engine/Build");
|
|
|
|
if (!Directory.Exists(engineBuildDirectory))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> EnumerateEngineInstallations(ILogger logger)
|
|
{
|
|
Dictionary<string, string> launcherEngineInstalls = EnumerateLauncherEngineInstallations(logger);
|
|
Dictionary<string, string> customEngineInstalls = EnumerateCustomEngineInstallations(logger);
|
|
|
|
Dictionary<string, string> allInstalls = new Dictionary<string, string>();
|
|
|
|
foreach (KeyValuePair<string, string> pair in launcherEngineInstalls)
|
|
{
|
|
allInstalls.Add(pair.Key, pair.Value);
|
|
}
|
|
|
|
foreach (KeyValuePair<string, string> pair in customEngineInstalls)
|
|
{
|
|
allInstalls.Add(pair.Key, pair.Value);
|
|
}
|
|
|
|
return allInstalls;
|
|
}
|
|
|
|
private static Dictionary<string, string> EnumerateLauncherEngineInstallations(ILogger logger)
|
|
{
|
|
Dictionary<string, string> installations = new Dictionary<string, string>();
|
|
|
|
try
|
|
{
|
|
string installedListFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Epic/UnrealEngineLauncher/LauncherInstalled.dat");
|
|
|
|
if (JsonObject.TryRead(new FileReference(installedListFilePath), out JsonObject? rawObject))
|
|
{
|
|
JsonObject[] installationArray = rawObject.GetObjectArrayField("InstallationList");
|
|
foreach (JsonObject installObject in installationArray)
|
|
{
|
|
string appName = installObject.GetStringField("AppName");
|
|
if (appName.StartsWith("UE_", StringComparison.Ordinal))
|
|
{
|
|
appName = appName.Remove(0, 3);
|
|
string installPath = NormalizeDirectoryName(installObject.GetStringField("InstallLocation"));
|
|
|
|
installations.Add(appName, installPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning("EnumerateLauncherEngineInstallations: {Message}", ex.Message);
|
|
installations.Clear();
|
|
}
|
|
|
|
return installations;
|
|
}
|
|
|
|
private static Dictionary<string, string> EnumerateCustomEngineInstallations(ILogger logger)
|
|
{
|
|
Dictionary<string, string> installations = new Dictionary<string, string>();
|
|
|
|
try
|
|
{
|
|
using RegistryKey? subKey = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Epic Games\\Unreal Engine\\Builds", false);
|
|
if (subKey != null)
|
|
{
|
|
foreach (string installName in subKey.GetValueNames())
|
|
{
|
|
string? installPath = subKey.GetValue(installName) as string;
|
|
if (!String.IsNullOrEmpty(installPath))
|
|
{
|
|
installations.Add(installName, NormalizeDirectoryName(installPath));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning("EnumerateCustomEngineInstallations: {Message}", ex.Message);
|
|
installations.Clear();
|
|
}
|
|
|
|
return installations;
|
|
}
|
|
|
|
#endregion
|
|
|
|
// The following code acts as an extension to code found @Engine\Source\Programs\Shared\EpicGames.Perforce\PerforceConnection.cs
|
|
#region PerforceConnection
|
|
|
|
// @todo None of the submit functions in PerforceConnection.cs seem to report submit errors in a way that we can inform the user
|
|
// so this is a custom version that returns the entire response list as different submit errors will return different error
|
|
// responses. We should fix the API in PerforceConnection so that other areas of code can get better submit error reporting.
|
|
private static async Task<PerforceResponseList<SubmitRecord>> TrySubmitAsync(IPerforceConnection connection, int changeNumber, SubmitOptions options, CancellationToken cancellationToken = default)
|
|
{
|
|
List<string> arguments = new List<string>();
|
|
if ((options & SubmitOptions.ReopenAsEdit) != 0)
|
|
{
|
|
arguments.Add("-r");
|
|
}
|
|
arguments.Add($"-c{changeNumber}");
|
|
|
|
return (await connection.CommandAsync<SubmitRecord>("submit", arguments, null, cancellationToken));
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|