// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; using System.Xml; using EpicGames.Core; using Microsoft.Extensions.Logging; namespace UnrealBuildTool { /// /// AppX Package Resource Index generator /// public class UEAppXResources { const string BuildResourceSubPath = "Resources"; const string DefaultResources = ""; class Resources { public Resources() { } public Dictionary SourceStrings = new(); // raw source strings, as registered by AddCultureString etc. public Dictionary StringResources = new(); // string resources that will be used, as defined by AddResourceString }; readonly Dictionary PerCultureResources = new(); readonly Dictionary FilesToCopy = new(); // source file -> target file fragment (relative to OutputDir) readonly ILogger Logger; readonly FileReference MakePriExe; // path to makepri.exe /// /// Folders where binary resource files are located, in decreasing priority order /// public List ProjectBinaryResourceDirectories { get; } /// /// Folders where fallback binary resource files are located, in decreasing priority order /// public List EngineFallbackBinaryResourceDirectories { get; } /// /// Target OS version /// public string TargetOSVersion { get; set; } /// /// Create a new instance /// public UEAppXResources(ILogger InLogger, FileReference InMakePriExe) { Logger = InLogger; MakePriExe = InMakePriExe; ProjectBinaryResourceDirectories = new(); EngineFallbackBinaryResourceDirectories = new(); TargetOSVersion = "10.0.0"; if (FileReference.Exists(MakePriExe) == false) { throw new BuildException($"Couldn't find the makepri executable: {MakePriExe}"); } AddCulture(DefaultResources); } /// /// Adds a reference to the given culture /// public void AddCulture(string AppXCultureId) { if (!PerCultureResources.TryGetValue(AppXCultureId, out Resources? Result) || Result == null) { PerCultureResources.Add(AppXCultureId, new()); } } /// /// Adds a reference to all of the given cultures /// /// public void AddCultures(IEnumerable AppXCultureIds) { foreach (string AppXCultureId in AppXCultureIds) { AddCulture(AppXCultureId); } } /// /// Returns a collection of all registered cultures /// /// public IEnumerable GetAllCultureIds() { return PerCultureResources.Keys.Where(X => !String.IsNullOrEmpty(X)); } /// /// Add the given string to the default culture /// public void AddDefaultString(string ConfigKey, string Value) { AddCultureString(DefaultResources, ConfigKey, Value); } /// /// Adds all of the given strings to the default culture /// public void AddDefaultStrings(Dictionary Strings) { AddCultureStrings(DefaultResources, Strings); } /// /// Add the given string to the given culture /// public void AddCultureString(string AppXCultureId, string ConfigKey, string Value) { GetCultureResources(AppXCultureId).SourceStrings[ConfigKey] = Value; } /// /// Adds all of the given strings to the given culture /// public void AddCultureStrings(string AppXCultureId, Dictionary Strings) { Resources ThisCultureResources = GetCultureResources(AppXCultureId); foreach (KeyValuePair String in Strings) { ThisCultureResources.SourceStrings[String.Key] = String.Value; } } /// /// Returns whether the given string is known /// public bool HasString(string Key) { return GetDefaultResources().SourceStrings.ContainsKey(Key); } /// /// Clear all stored strings for all cultures /// public void ClearStrings() { foreach (KeyValuePair Itr in PerCultureResources) { Itr.Value.SourceStrings.Clear(); } } Resources GetCultureResources(string AppXCultureId) { if (PerCultureResources.TryGetValue(AppXCultureId, out Resources? Result) && Result != null) { return Result; } else { throw new Exception($"Culture {AppXCultureId} is not known. Missing call to AddCulture?"); } } Resources GetDefaultResources() { return GetCultureResources(DefaultResources); } bool RunMakePri(string CommandLine) { StringBuilder ProcessOutput = new StringBuilder(); void LocalProcessOutput(DataReceivedEventArgs Args) { if (Args != null && Args.Data != null) { ProcessOutput.AppendLine(Args.Data.TrimEnd()); } } ProcessStartInfo StartInfo = new ProcessStartInfo(MakePriExe.FullName, CommandLine); StartInfo.UseShellExecute = false; StartInfo.CreateNoWindow = true; StartInfo.StandardOutputEncoding = Encoding.Unicode; StartInfo.StandardErrorEncoding = Encoding.Unicode; Process LocalProcess = new Process(); LocalProcess.StartInfo = StartInfo; LocalProcess.OutputDataReceived += (Sender, Args) => { LocalProcessOutput(Args); }; LocalProcess.ErrorDataReceived += (Sender, Args) => { LocalProcessOutput(Args); }; int ExitCode = Utils.RunLocalProcess(LocalProcess); if (ExitCode == 0) { Logger.LogDebug("{Output}", ProcessOutput.ToString()); return true; } else { Logger.LogInformation("{Output}", ProcessOutput.ToString()); Logger.LogError("{File} returned an error.", MakePriExe.GetFileName()); Logger.LogError("Exit code: {Code}", ExitCode); return false; } } private bool RemoveStaleResourceFiles(IEnumerable RequiredFileFragments, DirectoryReference OutputDirectory) { DirectoryReference ResourceDirectory = DirectoryReference.Combine(OutputDirectory, BuildResourceSubPath); if (!DirectoryReference.Exists(ResourceDirectory)) { return false; } // remove all files in the Resources/ subfolder that should not be included IEnumerable ExistingFiles = DirectoryReference.EnumerateFiles(ResourceDirectory, "*.*", SearchOption.AllDirectories); IEnumerable RequiredFiles = RequiredFileFragments .Select(X => FileReference.Combine(OutputDirectory, X)) .Where(X => X.IsUnderDirectory(ResourceDirectory)) ; IEnumerable StaleResourceFiles = ExistingFiles.Except(RequiredFiles); if (!StaleResourceFiles.Any()) { return false; } Logger.LogDebug("Removing stale manifest resource files..."); foreach (FileReference StaleResourceFile in StaleResourceFiles) { // try to delete the file & the directory that contains it try { Logger.LogDebug(" removing {Path}", StaleResourceFile.MakeRelativeTo(OutputDirectory)); FileUtils.ForceDeleteFile(StaleResourceFile); if (!Directory.EnumerateFileSystemEntries(StaleResourceFile.Directory.FullName).Any()) { DirectoryReference.Delete(StaleResourceFile.Directory, false); } } catch (Exception E) { Logger.LogError(" Could not remove {StaleResourceFile} - {Message}.", StaleResourceFile, E.Message); } } return true; } /// /// Attempts to locate the given resource binary file in the previously-specified resource folders /// bool FindResourceBinaryFile([NotNullWhen(true)] out FileReference? SourceFilePath, string ResourceFileName, string CultureId = "", bool AllowEngineFallback = true) { // look in project binary resource directories foreach (DirectoryReference BinaryResourceDirectory in ProjectBinaryResourceDirectories) { FileReference BinaryResourceFile = FileReference.Combine(BinaryResourceDirectory, CultureId, ResourceFileName); if (FileReference.Exists(BinaryResourceFile)) { SourceFilePath = BinaryResourceFile; return true; } } // look in Engine, if allowed if (AllowEngineFallback) { foreach (DirectoryReference BinaryResourceDirectory in EngineFallbackBinaryResourceDirectories) { FileReference BinaryResourceFile = FileReference.Combine(BinaryResourceDirectory, CultureId, ResourceFileName); if (FileReference.Exists(BinaryResourceFile)) { SourceFilePath = BinaryResourceFile; return true; } } } // not found SourceFilePath = null; return false; } /// /// Determines whether the given resource binary file can be found in one of the known folder locations for the given culture /// public bool DoesCultureResourceBinaryFileExist(string ResourceFileName, string CultureId, bool AllowEngineFallback = true) { return FindResourceBinaryFile(out FileReference? _, ResourceFileName, CultureId, AllowEngineFallback); } /// /// Determines whether the given default resource binary file can be found in one of the known folder locations /// public bool DoesDefaultResourceBinaryFileExist(string ResourceFileName, bool AllowEngineFallback = true) { return DoesCultureResourceBinaryFileExist(ResourceFileName, DefaultResources, AllowEngineFallback); } /// /// Adds the given resource binary file(s) to the manifest files /// public bool AddResourceBinaryFileReference(string ResourceFileName, bool AllowEngineFallback = true) { // At least the default culture entry for any resource binary must always exist if (!FindResourceBinaryFile(out FileReference? SourceFilePath, ResourceFileName, DefaultResources, AllowEngineFallback)) { return false; } AddFileReference(SourceFilePath, Path.Combine(BuildResourceSubPath, ResourceFileName)); // Copy all per-culture resource files foreach (string CultureId in GetAllCultureIds()) { if (FindResourceBinaryFile(out SourceFilePath, ResourceFileName, CultureId, AllowEngineFallback)) { AddFileReference(SourceFilePath, Path.Combine(BuildResourceSubPath, CultureId, ResourceFileName)); } } return true; } /// /// Adds the given file to the manifest files /// public void AddFileReference(FileReference SourcePath, string TargetPathFragment) { FilesToCopy.Add(SourcePath, TargetPathFragment); } private static bool AreFilesDifferent(FileReference File1, FileReference File2) { FileInfo FileInfo1 = File1.ToFileInfo(); FileInfo FileInfo2 = File2.ToFileInfo(); if (FileInfo1.Length != FileInfo2.Length) { return true; } byte[] FileContents1 = FileReference.ReadAllBytes(File1); byte[] FileContents2 = FileReference.ReadAllBytes(File2); return !Enumerable.SequenceEqual(FileContents1, FileContents2); } /// /// Copies all of the generated files to the output folder /// private List CopyFilesToOutput(DirectoryReference OutputDirectory) { List UpdatedFiles = new(); if (!FilesToCopy.Any()) { return UpdatedFiles; } Logger.LogDebug("Updating manifest resource files..."); foreach (KeyValuePair FileToCopy in FilesToCopy) { FileReference SourceFile = FileToCopy.Key; FileReference TargetFile = FileReference.Combine(OutputDirectory, FileToCopy.Value); if (!FileReference.Exists(SourceFile)) { Logger.LogError(" Source file not found {TargetFile}", TargetFile); continue; } if (FileReference.Exists(TargetFile) && !AreFilesDifferent(TargetFile, SourceFile)) { continue; } // remove old version, if any bool bFileExists = FileReference.Exists(TargetFile); if (bFileExists) { try { FileUtils.ForceDeleteFile(TargetFile); } catch (Exception E) { Logger.LogError(" Could not replace file {TargetFile} - {Message}", TargetFile, E.Message); } } // copy new version try { if (bFileExists) { Logger.LogDebug(" updating {Path}", Utils.MakePathRelativeTo(TargetFile.FullName, OutputDirectory.FullName!)); } else { Logger.LogDebug(" adding {Path}", Utils.MakePathRelativeTo(TargetFile.FullName, OutputDirectory.FullName!)); } Directory.CreateDirectory(Path.GetDirectoryName(TargetFile.FullName)!); FileReference.Copy(SourceFile, TargetFile); FileReference.SetAttributes(TargetFile, FileAttributes.Normal); File.SetCreationTime(TargetFile.FullName, File.GetCreationTime(SourceFile.FullName)); UpdatedFiles.Add(TargetFile); } catch (Exception E) { Logger.LogError(" Unable to copy file {TargetFile} - {Message}", TargetFile, E.Message); } } return UpdatedFiles; } /// /// Adds the given string to the culture string writers /// public string AddResourceString(string ResourceEntryName, string ConfigKey, string DefaultValue, string ValueSuffix = "") { // Get the default culture value Resources DefaultCultureResources = GetDefaultResources(); string? DefaultCultureString = null; if (DefaultCultureResources.SourceStrings.TryGetValue(ConfigKey, out string? Value) && Value != null) { DefaultCultureString = Value; } if (String.IsNullOrEmpty(DefaultCultureString)) { DefaultCultureString = DefaultValue; } DefaultCultureResources.StringResources.Add(ResourceEntryName, DefaultCultureString + ValueSuffix); // Get the localized culture values foreach (string CultureId in GetAllCultureIds()) { Resources ThisCultureResources = GetCultureResources(CultureId); if (ThisCultureResources.SourceStrings.TryGetValue(ConfigKey, out string? CultureString) && !String.IsNullOrEmpty(CultureString)) { ThisCultureResources.StringResources.Add(ResourceEntryName, CultureString + ValueSuffix); } } return "ms-resource:" + ResourceEntryName; } /// /// Generate the package resource index and copies all resources files to the output /// public List GenerateAppXResources(DirectoryReference OutputDirectory, DirectoryReference IntermediateDirectory, FileReference ManifestFile, string DefaultAppXCultureId, string? PackageIdentityName) { // Clean out the resources intermediate path so that we know there are no stale binary files. FileUtils.ForceDeleteDirectory(DirectoryReference.Combine(IntermediateDirectory, BuildResourceSubPath)); // Finalize all cultures foreach (KeyValuePair Itr in PerCultureResources) { string AppXCultureId = Itr.Key; // Create the culture folders DirectoryReference IntermediateCultureResourceDirectory = DirectoryReference.Combine(IntermediateDirectory, BuildResourceSubPath, AppXCultureId); DirectoryReference OutputCultureResourceDirectory = DirectoryReference.Combine(OutputDirectory, BuildResourceSubPath, AppXCultureId); FileUtils.CreateDirectoryTree(IntermediateCultureResourceDirectory); FileUtils.CreateDirectoryTree(OutputCultureResourceDirectory); // Export the resource tables & add them to the list of files to copy FileReference IntermediateResWIndexerFile = FileReference.Combine(IntermediateDirectory, BuildResourceSubPath, AppXCultureId, "resources.resw"); UEResXWriter ResourceWriter = new(IntermediateResWIndexerFile.FullName); foreach (KeyValuePair StringResourcePair in Itr.Value.StringResources) { ResourceWriter.AddResource(StringResourcePair.Key, StringResourcePair.Value); } ResourceWriter.Close(); AddFileReference(IntermediateResWIndexerFile, Path.Combine(BuildResourceSubPath, AppXCultureId, "resources.resw")); } // The resource database is dependent on everything else calculated here (manifest, resource string tables, binary resources). // So if any file has been updated we'll need to run the config. bool bHadStaleResources = RemoveStaleResourceFiles(FilesToCopy.Values, OutputDirectory); List UpdatedFilePaths = CopyFilesToOutput(OutputDirectory); FileReference TargetResourceIndexFile = FileReference.Combine(OutputDirectory, "resources.pri"); if (bHadStaleResources || UpdatedFilePaths.Any() || !FileReference.Exists(TargetResourceIndexFile)) { // Create resource index configuration FileReference ResourceConfigFile = FileReference.Combine(IntermediateDirectory, "priconfig.xml"); RunMakePri($"createconfig /cf \"{ResourceConfigFile}\" /dq {DefaultAppXCultureId} /o /pv {TargetOSVersion}"); // Load the new resource index configuration XmlDocument PriConfig = new XmlDocument(); PriConfig.Load(ResourceConfigFile.FullName); // remove the packaging node - we do not want to split the pri & only want one .pri file XmlNode PackagingNode = PriConfig.SelectSingleNode("/resources/packaging")!; PackagingNode.ParentNode!.RemoveChild(PackagingNode); // all required resources are explicitly listed in resources.resfiles, rather than relying on makepri to discover them FileReference ResourcesResFile = FileReference.Combine(IntermediateDirectory, "resources.resfiles"); XmlNode PriIndexNode = PriConfig.SelectSingleNode("/resources/index")!; XmlAttribute PriStartIndex = PriIndexNode.Attributes!["startIndexAt"]!; PriStartIndex.Value = ResourcesResFile.FullName; // swap the folder indexer-config to a RESFILES indexer-config. XmlElement FolderIndexerConfigNode = (XmlElement)PriConfig.SelectSingleNode("/resources/index/indexer-config[@type='folder']")!; FolderIndexerConfigNode.SetAttribute("type", "RESFILES"); FolderIndexerConfigNode.RemoveAttribute("foldernameAsQualifier"); FolderIndexerConfigNode.RemoveAttribute("filenameAsQualifier"); PriConfig.Save(ResourceConfigFile.FullName); // generate resources.resfiles IEnumerable Resources = DirectoryReference.EnumerateFiles(DirectoryReference.Combine(OutputDirectory, BuildResourceSubPath), "*.*", SearchOption.AllDirectories); System.Text.StringBuilder ResourcesList = new System.Text.StringBuilder(); foreach (FileReference Resource in Resources) { ResourcesList.AppendLine(Resource.MakeRelativeTo(OutputDirectory)); } File.WriteAllText(ResourcesResFile.FullName, ResourcesList.ToString()); // remove old Package Resource Index FileUtils.ForceDeleteFile(TargetResourceIndexFile); // generate new Package Resource Index FileReference ResourceLogFile = FileReference.Combine(IntermediateDirectory, "ResIndexLog.xml"); string MakePriCommandLine = $"new /pr \"{OutputDirectory}\" /cf \"{ResourceConfigFile}\" /mn \"{ManifestFile}\" /il \"{ResourceLogFile}\" /of \"{TargetResourceIndexFile}\" /o"; if (PackageIdentityName != null) { MakePriCommandLine += $" /indexName \"{PackageIdentityName}\""; } Logger.LogDebug(" generating {Path}", TargetResourceIndexFile.MakeRelativeTo(OutputDirectory)); RunMakePri(MakePriCommandLine); UpdatedFilePaths.Add(TargetResourceIndexFile); // .resw files are not needed - the data is embedded in the resources.pri foreach (FileReference ResW in DirectoryReference.EnumerateFiles(OutputDirectory, "*.resw", SearchOption.AllDirectories)) { FileUtils.ForceDeleteFile(ResW); } } // Report if nothing was changed if (!bHadStaleResources && !UpdatedFilePaths.Any()) { Logger.LogDebug($"Manifest resource files are up to date"); } return UpdatedFilePaths; } } }