#nullable enable using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.InteropServices; using Microsoft.Build.Construction; using T3.Core.Compilation; using T3.Core.Settings; using Encoding = System.Text.Encoding; // ReSharper disable SuggestBaseTypeForParameterInConstructor namespace T3.Editor.Compilation; /// /// A class that assists in the creation, modification, and compilation of a csproj file. /// Each editable project is currently represented by a csproj file, which is compiled and loaded at runtime. /// There are several unusual properties in the csproj file that are used to accommodate T3's feature set - see /// for more details. This can be considered a higher-level utility class that handles versioning, provides simple compilation methods, and provides /// straightforward access to properties within the csproj file. /// internal sealed class CsProjectFile { /// /// The path to the csproj file. /// public string FullPath => _projectRootElement.FullPath; /// /// The directory containing the csproj file. /// public string Directory => _projectRootElement.DirectoryPath; /// /// The name of the csproj file. /// public string Name => Path.GetFileNameWithoutExtension(FullPath); /// /// The root namespace of the project, as defined in the csproj file. /// public string RootNamespace => _projectRootElement.GetOrAddProperty(PropertyType.RootNamespace, Name); /// /// The version string of the project, as defined in the csproj file. /// public string VersionString => _projectRootElement.GetOrAddProperty(PropertyType.VersionPrefix, "1.0.0"); /// /// The version of the project, as defined in the csproj file. /// public Version Version => new(VersionString); public Guid PackageId { get { var idString = _projectRootElement.GetOrAddProperty(PropertyType.PackageId, string.Empty); return Guid.TryParse(idString, out var id) ? id : Guid.Empty; } } /// /// Returns the target dotnet framework for the project, or adds the default framework if none is found and returns that. /// private string TargetFramework => _projectRootElement.GetOrAddProperty(PropertyType.TargetFramework, ProjectXml.TargetFramework); /// /// Prevent loading a project /// public bool IsArchived { get => _projectRootElement.GetOrAddProperty(PropertyType.IsArchived, "false") == "true"; set { _projectRootElement.SetOrAddProperty(PropertyType.IsArchived, value ? "true" : "false"); UpdateLastModifiedDate(); // This saves the file } } public DateTime CreatedAt => _fileInfo.CreationTimeUtc; public DateTime ModifiedAt => _fileInfo.LastWriteTimeUtc; private CsProjectFile(ProjectRootElement projectRootElement) : this(projectRootElement, new FileInfo(projectRootElement.FullPath)) { } private CsProjectFile(ProjectRootElement projectRootElement, FileInfo fileInfo) { _projectRootElement = projectRootElement; _fileInfo = fileInfo; var targetFramework = TargetFramework; // check if the project needs its dotnet version upgraded. If so, update the project file accordingly. if (!ProjectXml.FrameworkIsCurrent(targetFramework)) { var newFramework = ProjectXml.UpdateFramework(targetFramework); _projectRootElement.SetOrAddProperty(PropertyType.TargetFramework, newFramework); } var dir = Directory; _releaseRootDirectory = Path.Combine(dir, "bin", "Release"); _debugRootDirectory = Path.Combine(dir, "bin", "Debug"); } /// /// Details about a loaded csproj file, including any errors that occurred during loading. /// public readonly struct CsProjectLoadInfo { public readonly string? Error; public readonly CsProjectFile? CsProjectFile; public readonly bool NeedsUpgrade; public readonly bool NeedsRecompile; public readonly List Warnings = []; internal CsProjectLoadInfo(CsProjectFile? file, string? error) { Error = error; CsProjectFile = file; if (file == null) { NeedsUpgrade = false; NeedsRecompile = false; return; } var targetFramework = file.TargetFramework; var currentFramework = RuntimeInformation.FrameworkDescription; // todo - additional version checks var needsUpgrade = !targetFramework.Contains(currentFramework); NeedsUpgrade = needsUpgrade; var csProjContents = file._projectRootElement; // 1. Migration: Ensure PackageId exists var currentPackageId = csProjContents.GetOrAddProperty(PropertyType.PackageId, string.Empty); if (string.IsNullOrWhiteSpace(currentPackageId) || currentPackageId == Guid.Empty.ToString()) { var newId = Guid.NewGuid().ToString(); csProjContents.SetOrAddProperty(PropertyType.PackageId, newId); Warnings.Add($"Assigned new unique PackageId ({newId}) to {file.Name}"); Log.Info($"Migrated {file.Name} with stable PackageId: {newId}"); } // 2. Migration: Repair/Update CreatePackageInfo target var packageTarget = csProjContents.Targets.FirstOrDefault(t => t.Name == "CreatePackageInfo"); // Check if the target is missing the PackageId field in its JSON template bool isOutdated = packageTarget != null && !packageTarget.Children .OfType() .Any(g => g.Properties.Any(p => p.Value.Contains("PackageId"))); if (packageTarget == null || isOutdated) { if (packageTarget != null) { csProjContents.RemoveChild(packageTarget); Warnings.Add($"Updating outdated CreatePackageInfo target in {file.Name}"); } // Re-inject the latest target defined in ProjectXml csProjContents.AddPackageInfoTarget(); } // 3. Hotfixes: check items for correctness foreach (var group in csProjContents.ItemGroups) { foreach (var item in group.Items) { #region Hotfix June-2-2025 if (item.ItemType == "Reference" && item.Include.Contains(ProjectSetup.EnvironmentVariableName)) { foreach (var metadata in item.Metadata) { if (!metadata.ExpressedAsAttribute || metadata.Name != "Private") continue; if (metadata.Value == "false") continue; metadata.Value = "false"; var warning = $"Modified {item} ({item.Include}) to set {metadata.Name} to {metadata.Value}"; Warnings.Add(warning); Log.Warning(warning); } } // Migration: Update Resources inclusion to Assets if (item.ItemType == "Content" && item.Include.Contains("Resources/")) { // 1. Update the inclusion path item.Include = item.Include.Replace("Resources/", "Assets/"); // 2. Update the Link metadata if it exists foreach (var metadata in item.Metadata) { if (metadata.Name == "Link" && metadata.Value.Contains("Resources/")) { metadata.Value = metadata.Value.Replace("Resources/", "Assets/"); } } Warnings.Add($"Automated migration of content paths from Resources/ to Assets/ in {file.Name}"); // This triggers csProjContents.HasUnsavedChanges, which causes the file to be saved automatically below } #endregion } } // 4. Hotfixes: Check properties for correctness var outputPathPropertyName = PropertyType.OutputPath.GetItemName(); foreach (var group in csProjContents.PropertyGroups) { foreach (var property in group.Properties) { if (property == null) continue; #region Project updates post-June-26-2025 if (property.Name == outputPathPropertyName) { Log.Debug($"Removing OutputPath property from {file.FullPath}"); group.RemoveChild(property); Warnings.Add($"Removed OutputPath property from {file.FullPath}"); } #endregion } } if (csProjContents.AddCleanBuildTarget()) { Warnings.Add($"Added clean build target to {file.FullPath}"); } // 5. Finalize changes and trigger recompile if necessary if (csProjContents.HasUnsavedChanges) { Warnings.Add($"Saving corrections to {file.FullPath}"); try { csProjContents.Save(); } catch (Exception e) { Warnings.Add($"Failed to save project file {file.FullPath}: {e.Message}"); Log.Error($"Failed to save project file {file.FullPath}: {e}"); } NeedsRecompile = true; } if (!NeedsRecompile) { var versionInfoDirectory = file.GetBuildTargetDirectory(); if (!AssemblyInformation.TryLoadReleaseInfo(versionInfoDirectory, out var releaseInfo)) { NeedsRecompile = true; Warnings.Add($"{file.Name} needs to be compiled because the version info file does not exist."); } else if (releaseInfo.Version != file.Version) { NeedsRecompile = true; Warnings.Add($"{file.Name} needs to be compiled because the existing build is a different version from our project file: ({releaseInfo.Version}) vs ({file.Version})."); } } } } /// /// Loads the csproj file at the given path, returning a struct that contains the loaded file and any errors. /// Does not actually handle any assemblies or type loading - it's just a way to load the xml file. /// /// True if successful public static bool TryLoad(FileInfo fileInfo, out CsProjectLoadInfo loadInfo) { bool success; var filePath = fileInfo.FullName; try { var fileContents = ProjectRootElement.Open(filePath); if (fileContents == null) { loadInfo = new CsProjectLoadInfo(null, $"Failed to open project file at \"{filePath}\""); success = false; } else { loadInfo = new CsProjectLoadInfo(new CsProjectFile(fileContents, fileInfo), null); success = true; } } catch (Exception e) { var error = $"Failed to open project file at \"{filePath}\":\n{e}"; loadInfo = new CsProjectLoadInfo(null, error); success = false; } // log any warnings that were generated during the load if (loadInfo.Warnings.Count > 0) { var name = loadInfo.CsProjectFile?.Name ?? Path.GetFileName(filePath); foreach (var warning in loadInfo.Warnings) { Log.Warning($"{name} {warning}"); } } return success; } /// /// Returns the directory where the primary dll for this project is built. This directory may or may not exist, as this is simply a "functional" /// way to generate the directory path. /// public string GetBuildTargetDirectory(Compiler.BuildMode buildMode = EditorBuildMode) { // this functionality should mirror the way that is defined in the csproj files return Path.Combine(GetRootDirectory(buildMode), TargetFramework); } /// /// Returns the debug & release build directories for this project. /// /// /// private string GetRootDirectory(Compiler.BuildMode buildMode) => buildMode == Compiler.BuildMode.Debug ? _debugRootDirectory : _releaseRootDirectory; // todo - rate limit recompiles for when multiple files change /// /// Compiles/recompiles this project in debug mode for runtime use in the Editor. /// /// True if NuGet packages should be restored /// The output of the compilation process /// True if successful public bool TryRecompile(bool nugetRestore, [NotNullWhen(false)] out string? output) { if (!Compiler.TryCompile(this, EditorBuildMode, nugetRestore, out output)) { return false; } return true; } public void UpdateVersionForIOChange(int modifyAmount) { ModifyBuildVersion(0, Math.Clamp(modifyAmount, -1, 1), 0); } private void ModifyBuildVersion(int majorModify, int minorModify, int buildModify) { _projectRootElement.SetOrAddProperty(PropertyType.EditorVersion, Program.Version.ToBasicVersionString()); var version = Version; var newVersion = new Version(version.Major + majorModify, version.Minor + minorModify, version.Build + buildModify); _projectRootElement.SetOrAddProperty(PropertyType.VersionPrefix, newVersion.ToBasicVersionString()); try { _projectRootElement.Save(); _fileInfo.Refresh(); } catch (Exception e) { Log.Error($"{Name}: Failed to save project file {_projectRootElement.FullPath} after modifying version: {e}"); } } /// /// For building release-mode assemblies for use in the Player. All other runtime-compilation is done in debug mode. /// /// True if NuGet packages should be restored /// Compilation process output in case of failure /// True if successful public bool TryCompileRelease(bool nugetRestore, [NotNullWhen(false)] out string? output) { return Compiler.TryCompile(this, PlayerBuildMode, nugetRestore, out output); } /// /// Updates the last modified date of the project file by re-saving the project XML. /// public void UpdateLastModifiedDate() { // we can set the last modified date by re-saving the project xml using var textWriter = new StreamWriter(_projectRootElement.FullPath); try { _projectRootElement.Save(textWriter); _fileInfo.Refresh(); } catch (Exception e) { Log.Error($"{Name}: Failed to save project file {_projectRootElement.FullPath}: {e}"); } } // todo- use Microsoft.Build.Construction and Microsoft.Build.Evaluation /// /// Creates a new .csproj file and populates all required files for a new T3 project. /// /// The name of the new project /// The root namespace of the new project /// True if the project should share its resources with other packages /// The directory inside which the new project should be created. The project will actually reside inside /// "parentDirectory/projectName" /// /// /// todo - find a better home for this /// todo: - files copied into the new project should be generated at runtime where possible - /// for example, the default home canvas symbol/symbolui /// should probably be copied from the Examples package wholesale, with replaced guids. /// public static CsProjectFile CreateNewProject(string projectName, string nameSpace, bool shareResources, string parentDirectory) { var defaultHomeDir = Path.Combine(FileLocations.ReadOnlySettingsPath, "default-home"); var files = System.IO.Directory.EnumerateFiles(defaultHomeDir, "*"); string destinationDirectory = Path.Combine(parentDirectory, projectName); destinationDirectory = Path.GetFullPath(destinationDirectory); System.IO.Directory.CreateDirectory(destinationDirectory); var dependenciesDirectory = Path.Combine(destinationDirectory, ProjectXml.DependenciesFolder); System.IO.Directory.CreateDirectory(dependenciesDirectory); var resourcesDirectory = Path.Combine(destinationDirectory, FileLocations.AssetsSubfolder); System.IO.Directory.CreateDirectory(resourcesDirectory); string placeholderDependencyPath = Path.Combine(dependenciesDirectory, "PlaceNativeDllDependenciesHere.txt"); File.Create(placeholderDependencyPath).Dispose(); // todo - use source generation and direct type references instead of this copy and replace strategy const string guidPlaceholder = "{{GUID}}"; const string nameSpacePlaceholder = "{{NAMESPACE}}"; const string usernamePlaceholder = "{{USER}}"; const string shareResourcesPlaceholder = "{{SHARE_RESOURCES}}"; const string projectNamePlaceholder = "{{PROJ}}"; var shouldShareResources = shareResources ? "true" : "false"; var username = nameSpace.Split('.').First(); var packageId = Guid.NewGuid(); var homeId = Guid.NewGuid(); var homeGuidString = homeId.ToString(); var projRoot = ProjectXml.CreateNewProjectRootElement(nameSpace, homeId, packageId); foreach (var file in files) { var text = File.ReadAllText(file) .Replace(projectNamePlaceholder, projectName) .Replace(guidPlaceholder, homeGuidString) .Replace(nameSpacePlaceholder, nameSpace) .Replace(usernamePlaceholder, username) .Replace(shareResourcesPlaceholder, shouldShareResources); var destinationFilePath = Path.Combine(destinationDirectory, Path.GetFileName(file)) .Replace(projectNamePlaceholder, projectName) .Replace(guidPlaceholder, homeGuidString); File.WriteAllText(destinationFilePath, text); } projRoot.FullPath = Path.Combine(destinationDirectory, $"{projectName}.csproj"); projRoot.Save(Encoding.UTF8); return new CsProjectFile(projRoot); } internal const Compiler.BuildMode EditorBuildMode = Compiler.BuildMode.Debug; internal const Compiler.BuildMode PlayerBuildMode = Compiler.BuildMode.Release; private readonly string _releaseRootDirectory; private readonly string _debugRootDirectory; private readonly ProjectRootElement _projectRootElement; private readonly FileInfo _fileInfo; public void IncrementBuildNumber(int amount) { if (amount == 0) return; ModifyBuildVersion(0, 0, amount); } }