Files
2026-07-13 13:13:17 +08:00

488 lines
20 KiB
C#

#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;
/// <summary>
/// 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 <see cref="ProjectXml"/>
/// 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.
/// </summary>
internal sealed class CsProjectFile
{
/// <summary>
/// The path to the csproj file.
/// </summary>
public string FullPath => _projectRootElement.FullPath;
/// <summary>
/// The directory containing the csproj file.
/// </summary>
public string Directory => _projectRootElement.DirectoryPath;
/// <summary>
/// The name of the csproj file.
/// </summary>
public string Name => Path.GetFileNameWithoutExtension(FullPath);
/// <summary>
/// The root namespace of the project, as defined in the csproj file.
/// </summary>
public string RootNamespace => _projectRootElement.GetOrAddProperty(PropertyType.RootNamespace, Name);
/// <summary>
/// The version string of the project, as defined in the csproj file.
/// </summary>
public string VersionString => _projectRootElement.GetOrAddProperty(PropertyType.VersionPrefix, "1.0.0");
/// <summary>
/// The version of the project, as defined in the csproj file.
/// </summary>
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;
}
}
/// <summary>
/// Returns the target dotnet framework for the project, or adds the default framework if none is found and returns that.
/// </summary>
private string TargetFramework => _projectRootElement.GetOrAddProperty(PropertyType.TargetFramework, ProjectXml.TargetFramework);
/// <summary>
/// Prevent loading a project
/// </summary>
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");
}
/// <summary>
/// Details about a loaded csproj file, including any errors that occurred during loading.
/// </summary>
public readonly struct CsProjectLoadInfo
{
public readonly string? Error;
public readonly CsProjectFile? CsProjectFile;
public readonly bool NeedsUpgrade;
public readonly bool NeedsRecompile;
public readonly List<string> 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<ProjectPropertyGroupElement>()
.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}).");
}
}
}
}
/// <summary>
/// Loads the csproj file at the given path, returning a <see cref="CsProjectLoadInfo"/> 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.
/// </summary>
/// <returns>True if successful</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
public string GetBuildTargetDirectory(Compiler.BuildMode buildMode = EditorBuildMode)
{
// this functionality should mirror the way that <OutputPath> is defined in the csproj files
return Path.Combine(GetRootDirectory(buildMode), TargetFramework);
}
/// <summary>
/// Returns the debug & release build directories for this project.
/// </summary>
/// <param name="buildMode"></param>
/// <returns></returns>
private string GetRootDirectory(Compiler.BuildMode buildMode) => buildMode == Compiler.BuildMode.Debug ? _debugRootDirectory : _releaseRootDirectory;
// todo - rate limit recompiles for when multiple files change
/// <summary>
/// Compiles/recompiles this project in debug mode for runtime use in the Editor.
/// </summary>
/// <param name="nugetRestore">True if NuGet packages should be restored</param>
/// <param name="output">The output of the compilation process</param>
/// <returns>True if successful</returns>
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}");
}
}
/// <summary>
/// For building release-mode assemblies for use in the Player. All other runtime-compilation is done in debug mode.
/// </summary>
/// <param name="nugetRestore">True if NuGet packages should be restored</param>
/// <param name="output">Compilation process output in case of failure</param>
/// <returns>True if successful</returns>
public bool TryCompileRelease(bool nugetRestore, [NotNullWhen(false)] out string? output)
{
return Compiler.TryCompile(this, PlayerBuildMode, nugetRestore, out output);
}
/// <summary>
/// Updates the last modified date of the project file by re-saving the project XML.
/// </summary>
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
/// <summary>
/// Creates a new .csproj file and populates all required files for a new T3 project.
/// </summary>
/// <param name="projectName">The name of the new project</param>
/// <param name="nameSpace">The root namespace of the new project</param>
/// <param name="shareResources">True if the project should share its resources with other packages</param>
/// <param name="parentDirectory">The directory inside which the new project should be created. The project will actually reside inside
/// "parentDirectory/projectName"</param>
/// <returns></returns>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}