// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Perforce; using Microsoft.Extensions.Logging; namespace UnrealGameSync { public class OpenProjectInfo { public UserSelectedProjectSettings SelectedProject { get; } public IPerforceSettings PerforceSettings { get; } public ProjectInfo ProjectInfo { get; } public UserWorkspaceSettings WorkspaceSettings { get; } public WorkspaceStateWrapper WorkspaceStateWrapper { get; } public ConfigFile LatestProjectConfigFile { get; } public ConfigFile WorkspaceProjectConfigFile { get; } public IReadOnlyList? WorkspaceProjectStreamFilter { get; } public List> LocalConfigFiles { get; } public bool GenerateP4Config { get; } public OpenProjectInfo(UserSelectedProjectSettings selectedProject, IPerforceSettings perforceSettings, ProjectInfo projectInfo, UserWorkspaceSettings workspaceSettings, WorkspaceStateWrapper workspaceStateWrapper, ConfigFile latestProjectConfigFile, ConfigFile workspaceProjectConfigFile, IReadOnlyList? workspaceProjectStreamFilter, List> localConfigFiles, bool generateP4Config) { SelectedProject = selectedProject; PerforceSettings = perforceSettings; ProjectInfo = projectInfo; WorkspaceSettings = workspaceSettings; WorkspaceStateWrapper = workspaceStateWrapper; LatestProjectConfigFile = latestProjectConfigFile; WorkspaceProjectConfigFile = workspaceProjectConfigFile; WorkspaceProjectStreamFilter = workspaceProjectStreamFilter; LocalConfigFiles = localConfigFiles; GenerateP4Config = generateP4Config; } public static async Task CreateAsync(IPerforceSettings defaultPerforceSettings, UserSelectedProjectSettings selectedProject, UserSettings userSettings, bool generateP4Config, ILogger logger, CancellationToken cancellationToken) { PerforceSettings perforceSettings = Utility.OverridePerforceSettings(defaultPerforceSettings, selectedProject.ServerAndPort, selectedProject.UserName); using IPerforceConnection perforce = await PerforceConnection.CreateAsync(perforceSettings, logger); // Make sure we're logged in PerforceResponse loginState = await perforce.TryGetLoginStateAsync(cancellationToken); if (!loginState.Succeeded) { throw new UserErrorException("User is not logged in to Perforce."); } // Execute like a regular task return await CreateAsync(perforce, selectedProject, userSettings, generateP4Config, logger, cancellationToken); } public static async Task CreateAsync(IPerforceConnection defaultConnection, UserSelectedProjectSettings selectedProject, UserSettings userSettings, bool generateP4Config, ILogger logger, CancellationToken cancellationToken) { using IDisposable? loggerScope = logger.BeginScope("Project {SelectedProject}", selectedProject.ToString()); logger.LogInformation("Detecting settings for {Project}", selectedProject); // Use the cached client path to the file if it's available; it's much quicker than trying to find the correct workspace. IPerforceConnection? perforceClient = null; try { IPerforceSettings perforceSettings; FileReference newSelectedFileName; string newSelectedClientFileName; if (!String.IsNullOrEmpty(selectedProject.ClientPath)) { // Get the client path newSelectedClientFileName = selectedProject.ClientPath; // Get the client name string? clientName; if (!PerforceUtils.TryGetClientName(newSelectedClientFileName, out clientName)) { throw new UserErrorException($"Couldn't get client name from {newSelectedClientFileName}"); } // Create the client perforceSettings = new PerforceSettings(defaultConnection.Settings) { ClientName = clientName }; perforceClient = await PerforceConnection.CreateAsync(perforceSettings, logger); // Figure out the path on the client. Use the cached location if it's valid. string? localPath = selectedProject.LocalPath; if (localPath == null || !File.Exists(localPath)) { List records = await perforceClient.WhereAsync(newSelectedClientFileName, cancellationToken).Where(x => !x.Unmap).ToListAsync(cancellationToken); if (records.Count != 1) { throw new UserErrorException($"Couldn't get client path for {newSelectedClientFileName}"); } localPath = Path.GetFullPath(records[0].Path); } newSelectedFileName = new FileReference(localPath); } else { // Get the perforce server settings InfoRecord perforceInfo = await defaultConnection.GetInfoAsync(InfoOptions.ShortOutput, cancellationToken); // Use the path as the selected filename newSelectedFileName = new FileReference(selectedProject.LocalPath!); // Make sure the project exists if (!FileReference.Exists(newSelectedFileName)) { throw new UserErrorException($"{selectedProject.LocalPath} does not exist."); } // Find all the clients for this user logger.LogInformation("Enumerating clients for {UserName}...", perforceInfo.UserName); List clients = await defaultConnection.GetClientsAsync(ClientsOptions.None, defaultConnection.Settings.UserName, cancellationToken); List candidateClients = await FilterClients(clients, newSelectedFileName, defaultConnection.Settings, perforceInfo.ClientHost, logger, cancellationToken); if (candidateClients.Count == 0) { // Search through all workspaces. We may find a suitable workspace which is for any user. logger.LogInformation("Enumerating shared clients..."); clients = await defaultConnection.GetClientsAsync(ClientsOptions.None, "", cancellationToken); // Filter this list of clients candidateClients = await FilterClients(clients, newSelectedFileName, defaultConnection.Settings, perforceInfo.ClientHost, logger, cancellationToken); #pragma warning disable CA1508 // False positive? "warning CA1508: 'newCandidateClients.Count == 0' is always 'true'. Remove or refactor the condition(s) to avoid dead code." // If we still couldn't find any, fail. if (candidateClients.Count == 0) { throw new UserErrorException($"Couldn't find any Perforce workspace containing {newSelectedFileName}. Check your connection settings."); } #pragma warning restore CA1508 } // Check there's only one client if (candidateClients.Count > 1) { throw new UserErrorException(String.Format("Found multiple workspaces containing {0}:\n\n{1}\n\nCannot determine which to use.", Path.GetFileName(newSelectedFileName.GetFileName()), String.Join("\n", candidateClients.Select(x => x.ClientName)))); } // Take the client we've chosen perforceSettings = candidateClients[0]; perforceClient = await PerforceConnection.CreateAsync(perforceSettings, logger); // Get the client path for the project file List records = await perforceClient.WhereAsync(newSelectedFileName.FullName, cancellationToken).Where(x => !x.Unmap).ToListAsync(cancellationToken); if (records.Count == 0) { throw new UserErrorException("File is not mapped to any client"); } else if (records.Count > 1) { throw new UserErrorException($"File is mapped to {records.Count} locations: {String.Join(", ", records.Select(x => x.Path))}"); } newSelectedClientFileName = records[0].ClientFile; } // Make sure the drive containing the project exists, to prevent other errors down the line string pathRoot = Path.GetPathRoot(newSelectedFileName.FullName)!; if (!Directory.Exists(pathRoot)) { throw new UserErrorException($"Path '{newSelectedFileName}' is invalid"); } // Make sure the path case is correct. This can cause UBT intermediates to be out of date if the case mismatches. newSelectedFileName = FileReference.FindCorrectCase(newSelectedFileName); // Update the selected project with all the data we've found selectedProject = new UserSelectedProjectSettings(selectedProject.ServerAndPort, selectedProject.UserName, selectedProject.Type, newSelectedClientFileName, newSelectedFileName.FullName); // Get the local branch root string? branchClientPath = null; DirectoryReference? branchDirectoryName = null; // Figure out where the engine is in relation to it int endIdx = newSelectedClientFileName.Length - 1; if (endIdx != -1 && newSelectedClientFileName.EndsWith(".uproject", StringComparison.InvariantCultureIgnoreCase)) { endIdx = newSelectedClientFileName.LastIndexOf('/') - 1; } for (; endIdx >= 2; endIdx--) { if (newSelectedClientFileName[endIdx] == '/') { List> fileRecords = await perforceClient.TryFStatAsync(FStatOptions.None, newSelectedClientFileName.Substring(0, endIdx) + "/Engine/Build/Build.version", cancellationToken).ToListAsync(cancellationToken); if (fileRecords.Succeeded() && fileRecords.Count > 0) { FStatRecord fileRecord = fileRecords[0].Data; if (fileRecord.ClientFile == null) { throw new UserErrorException($"Missing client path for {fileRecord.DepotFile}"); } branchClientPath = newSelectedClientFileName.Substring(0, endIdx); branchDirectoryName = new FileReference(fileRecord.ClientFile).Directory.ParentDirectory?.ParentDirectory; break; } } } if (branchClientPath == null || branchDirectoryName == null) { throw new UserErrorException($"Could not find engine in Perforce relative to project path ({newSelectedClientFileName})"); } logger.LogInformation("Found branch root at {RootPath}", branchClientPath); // Read the existing workspace settings from disk, and update them with any info computed here int branchIdx = branchClientPath.IndexOf('/', 2); string branchPath = (branchIdx == -1) ? String.Empty : branchClientPath.Substring(branchIdx); string projectPath = newSelectedClientFileName.Substring(branchClientPath.Length); UserWorkspaceSettings userWorkspaceSettings = userSettings.FindOrAddWorkspaceSettings(branchDirectoryName, perforceSettings.ServerAndPort, perforceSettings.UserName, perforceSettings.ClientName!, branchPath, projectPath, logger); // Now compute the updated project info ProjectInfo projectInfo = await ProjectInfo.CreateAsync(perforceClient, userWorkspaceSettings, cancellationToken); // Update the cached workspace state WorkspaceStateWrapper? workspaceStateWrapper = null; try { #pragma warning disable CA2000 workspaceStateWrapper = userSettings.FindOrAddWorkspaceState(projectInfo, userWorkspaceSettings); #pragma warning restore CA2000 // Read the initial config file List> localConfigFiles = new List>(); ConfigFile latestProjectConfigFile = await ConfigUtils.ReadProjectConfigFileAsync(perforceClient, projectInfo, localConfigFiles, logger, cancellationToken); // Get the local config file and stream filter ConfigFile workspaceProjectConfigFile = await WorkspaceUpdate.ReadProjectConfigFile(branchDirectoryName, newSelectedFileName, logger); IReadOnlyList? workspaceProjectStreamFilter = await WorkspaceUpdate.ReadProjectStreamFilter(perforceClient, workspaceProjectConfigFile, cancellationToken); OpenProjectInfo workspaceSettings = new OpenProjectInfo(selectedProject, perforceSettings, projectInfo, userWorkspaceSettings, workspaceStateWrapper, latestProjectConfigFile, workspaceProjectConfigFile, workspaceProjectStreamFilter, localConfigFiles, generateP4Config); if (generateP4Config) { GenerateP4ConfigFile(perforceSettings, projectInfo, logger); } return workspaceSettings; } catch { workspaceStateWrapper?.Dispose(); throw; } } finally { perforceClient?.Dispose(); } } static async Task> FilterClients(List clients, FileReference newSelectedFileName, IPerforceSettings defaultPerforceSettings, string? hostName, ILogger logger, CancellationToken cancellationToken) { List candidateClients = new List(); foreach (ClientsRecord client in clients) { // Make sure the client is well formed if (!String.IsNullOrEmpty(client.Name) && (!String.IsNullOrEmpty(client.Host) || !String.IsNullOrEmpty(client.Owner)) && !String.IsNullOrEmpty(client.Root)) { // Require either a username or host name match if ((String.IsNullOrEmpty(client.Host) || String.Equals(client.Host, hostName, StringComparison.OrdinalIgnoreCase)) && (String.IsNullOrEmpty(client.Owner) || String.Equals(client.Owner, defaultPerforceSettings.UserName, StringComparison.OrdinalIgnoreCase))) { if (!Utility.SafeIsFileUnderDirectory(newSelectedFileName.FullName, client.Root)) { logger.LogInformation("Rejecting {ClientName} due to root mismatch ({RootPath})", client.Name, client.Root); continue; } PerforceSettings candidateSettings = new PerforceSettings(defaultPerforceSettings) { ClientName = client.Name }; using IPerforceConnection candidateClient = await PerforceConnection.CreateAsync(candidateSettings, logger); List> whereRecords = await candidateClient.TryWhereAsync(newSelectedFileName.FullName, cancellationToken).Where(x => x.Failed || !x.Data.Unmap).ToListAsync(cancellationToken); if (!whereRecords.Succeeded() || whereRecords.Count != 1) { logger.LogInformation("Rejecting {ClientName} due to file not existing in workspace", client.Name); continue; } List> records = await candidateClient.TryFStatAsync(FStatOptions.None, newSelectedFileName.FullName, cancellationToken).ToListAsync(cancellationToken); if (!records.Succeeded()) { logger.LogInformation("Rejecting {ClientName} due to {FileName} not in depot", client.Name, newSelectedFileName); continue; } records.RemoveAll(x => !x.Data.IsMapped); if (records.Count == 0) { logger.LogInformation("Rejecting {ClientName} due to {NumRecords} matching records", client.Name, records.Count); continue; } logger.LogInformation("Found valid client {ClientName}", client.Name); candidateClients.Add(candidateSettings); } } } return candidateClients; } static void GenerateP4ConfigFile(IPerforceSettings perforceSettings, ProjectInfo projectInfo, ILogger logger) { string? p4ConfigName = PerforceEnvironment.Default.GetValue("P4CONFIG"); // We only create a p4config if the user has opted into the system by setting P4CONFIG manually. if (String.IsNullOrEmpty(p4ConfigName)) { logger.LogError("Could not generate a p4config file as the envvar P4CONFIG is not set!"); return; } string configFilePath = Path.Combine(projectInfo.LocalRootPath.ToString(), p4ConfigName); // Do not update or replace the file if it already exists if (File.Exists(configFilePath)) { logger.LogDebug("P4CONFIG file '{Path}' already exists.", configFilePath); return; } Dictionary settings = new Dictionary(); settings.Add("P4PORT", perforceSettings.ServerAndPort); settings.Add("P4USER", perforceSettings.UserName); settings.Add("P4CLIENT", perforceSettings.ClientName ?? ""); List Lines = new List(); foreach (KeyValuePair setting in settings) { Lines.Add(String.Format("{0}={1}", setting.Key, setting.Value)); } try { File.WriteAllLines(configFilePath, Lines); } catch (Exception ex) { logger.LogError(ex, "Failed to write {Path} with exception - {Msg}", p4ConfigName, ex.Message); return; } logger.LogInformation("Successfully wrote a P4CONFIG file to '{Path}'.", configFilePath); } } }