#if ENABLE_CCD using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Threading.Tasks; using Unity.Services.Ccd.Management; using Unity.Services.Ccd.Management.Models; using Unity.Services.Core; using UnityEditor.AddressableAssets.Settings; using UnityEditor.AddressableAssets.Settings.GroupSchemas; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.AddressableAssets.ResourceLocators; namespace UnityEditor.AddressableAssets.Build { /// /// CCD Events used for building Addressables content with CCD. /// public class CcdBuildEvents { static CcdBuildEvents s_Instance; /// /// The static instance of CcdBuildEvents. /// public static CcdBuildEvents Instance { get { if (s_Instance == null) { s_Instance = new CcdBuildEvents(); s_Instance.RegisterNewBuildEvents(); s_Instance.RegisterUpdateBuildEvents(); } return s_Instance; } } const string k_ContentStatePath = "addressables_content_state.bin"; internal void RegisterNewBuildEvents() { OnPreBuildEvents += s_Instance.VerifyBuildVersion; OnPreBuildEvents += s_Instance.RefreshDataSources; OnPreBuildEvents += s_Instance.VerifyTargetBucket; OnPostBuildEvents += s_Instance.UploadContentState; OnPostBuildEvents += s_Instance.UploadAndRelease; } internal void RegisterUpdateBuildEvents() { OnPreUpdateEvents += s_Instance.VerifyBuildVersion; OnPreUpdateEvents += s_Instance.RefreshDataSources; OnPreUpdateEvents += s_Instance.DownloadContentStateBin; OnPreUpdateEvents += s_Instance.VerifyTargetBucket; OnPostUpdateEvents += s_Instance.UploadContentState; OnPostUpdateEvents += s_Instance.UploadAndRelease; } /// /// Pre Addressables build event. /// public delegate Task PreEvent(AddressablesDataBuilderInput input); /// /// Pre new build events. /// Default events: /// /// /// public static event PreEvent OnPreBuildEvents; /// /// Pre update build events. /// Default events: /// /// /// /// public static event PreEvent OnPreUpdateEvents; /// /// Post Addressables build event. /// public delegate Task PostEvent(AddressablesDataBuilderInput input, AddressablesPlayerBuildResult result); /// /// Post new build events. /// Default events: /// /// /// public static event PostEvent OnPostBuildEvents; /// /// Post update build events. /// Default events: /// /// /// public static event PostEvent OnPostUpdateEvents; internal async Task OnPreEvent(bool isUpdate, AddressablesDataBuilderInput input) { if (isUpdate) { return await InvokePreEvent(OnPreUpdateEvents, input); } return await InvokePreEvent(OnPreBuildEvents, input); } internal async Task InvokePreEvent(PreEvent events, AddressablesDataBuilderInput input) { if (events == null) { return true; } var total = events.GetInvocationList().Length; for (var i = 0; i < total; i++) { var e = (PreEvent)events.GetInvocationList()[i]; var shouldContinue = await e.Invoke(input); if (!shouldContinue) { return false; } } return true; } internal async Task OnPostEvent(bool isUpdate, AddressablesDataBuilderInput input, AddressablesPlayerBuildResult result) { if (isUpdate) { return await InvokePostEvent(OnPostUpdateEvents, input, result); } return await InvokePostEvent(OnPostBuildEvents, input, result); } internal async Task InvokePostEvent(PostEvent events, AddressablesDataBuilderInput input, AddressablesPlayerBuildResult result) { if (events == null) return true; var total = events.GetInvocationList().Length; for (var i = 0; i < total; i++) { var e = (PostEvent)events.GetInvocationList()[i]; var shouldContinue = await e.Invoke(input, result); if (!shouldContinue) { // if a post-build step adds an error we have to log it manually if (result != null && result.Error != "") { Debug.LogError(result.Error); } return false; } } return true; } /// /// Prepend an event to the pre new build events. /// /// Pre build event public static void PrependPreBuildEvent(PreEvent newEvent) { Delegate[] oldEvents = OnPreBuildEvents?.GetInvocationList(); OnPreBuildEvents = newEvent; if (oldEvents != null) foreach (var t in oldEvents) { OnPreBuildEvents += (PreEvent)(t); } } /// /// Prepend an event to the post new build events. /// /// Post build event public static void PrependPostBuildEvent(PostEvent newEvent) { Delegate[] oldEvents = OnPostBuildEvents?.GetInvocationList(); OnPostBuildEvents = newEvent; if (oldEvents != null) foreach (var t in oldEvents) { OnPostBuildEvents += (PostEvent)(t); } } /// /// Prepend an event to the pre update build events. /// /// Pre build event public static void PrependPreUpdateEvent(PreEvent newEvent) { Delegate[] oldEvents = OnPreUpdateEvents?.GetInvocationList(); OnPreUpdateEvents = newEvent; if (oldEvents != null) foreach (var t in oldEvents) { OnPreUpdateEvents += (PreEvent)(t); } } /// /// Prepend an event to the post update build events. /// /// Post build event public static void PrependPostUpdateEvent(PostEvent newEvent) { Delegate[] oldEvents = OnPostUpdateEvents?.GetInvocationList(); OnPostUpdateEvents = newEvent; if (oldEvents != null) foreach (var t in oldEvents) { OnPostUpdateEvents += (PostEvent)(t); } } public async Task VerifyBuildVersion(AddressablesDataBuilderInput input) { if (string.IsNullOrWhiteSpace(input.AddressableSettings.OverridePlayerVersion)) { Addressables.LogWarning("When using CCD it is recommended that you set a 'Player Version Override' in Addressables Settings. You can have it use the Player build version by setting it to [UnityEditor.PlayerSettings.bundleVersion]."); Addressables.LogWarning("Documentation on how to disable this warning is available in the example DisableBuildWarnings.cs."); } return true; } /// /// Update the CCD data source settings. /// /// Addressables data builder context /// public async Task RefreshDataSources(AddressablesDataBuilderInput input) { try { var projectId = CloudProjectSettings.projectId; await ProfileDataSourceSettings.UpdateCCDDataSourcesAsync(projectId, true); } catch (Exception e) { Debug.LogError(e.ToString()); return false; } return true; } internal async Task LoopGroups(AddressableAssetSettings settings, Func> action) { var tasks = new List>(); foreach (var group in settings.groups) { if (group == null) { continue; } var schema = group.GetSchema(); if (schema == null) { continue; } tasks.Add(action(settings, group, schema)); } var results = await Task.WhenAll(tasks.ToArray()); foreach (var result in results) { // if any fail, all fail if (result == false) { return false; } } return true; } /// /// Verify that the targeted CCD bucket exists or create it. /// /// Addressables data builder context /// public async Task VerifyTargetBucket(AddressablesDataBuilderInput input) { if (input.AddressableSettings == null) { string error; if (EditorApplication.isUpdating) error = "Addressable Asset Settings does not exist. EditorApplication.isUpdating was true."; else if (EditorApplication.isCompiling) error = "Addressable Asset Settings does not exist. EditorApplication.isCompiling was true."; else error = "Addressable Asset Settings does not exist. Failed to create."; Debug.LogError(error); return false; } if (!hasRemoteGroups(input.AddressableSettings)) { Debug.LogWarning("No Addressable Asset Groups have been marked remote or the current profile is not using CCD."); if (input.AddressableSettings.BuildRemoteCatalog) { Debug.LogWarning("A remote catalog will be built without any remote Asset Bundles."); } } // Reclean directory before every build if (Directory.Exists(AddressableAssetSettings.kCCDBuildDataPath)) { Directory.Delete(AddressableAssetSettings.kCCDBuildDataPath, true); } return await LoopGroups(input.AddressableSettings, verifyTargetBucket); } internal bool hasRemoteGroups(AddressableAssetSettings settings) { foreach (var group in settings.groups) { if (group == null) { continue; } var schema = group.GetSchema(); if (schema == null) { continue; } var foundGroupType = GetGroupType(settings, schema); if (isCCDGroup(foundGroupType)) { return true; } } return false; } internal bool isCCDGroup(ProfileGroupType groupType) { if (groupType == null) { return false; } if (IsUsingManager(groupType)) { return true; } return groupType.GroupTypePrefix.StartsWith("CCD"); } internal async Task verifyTargetBucket(AddressableAssetSettings settings, AddressableAssetGroup group, BundledAssetGroupSchema schema) { AddressableAssetSettings.NullifyBundleFileIds(group); // if not using the manager try to lookup the bucket and verify it's not promotion only var existingGroupType = GetDefaultGroupType(ProfileDataSourceSettings.GetSettings()); if (!IsUsingManager(settings, schema)) { if (existingGroupType != null) { var promotionOnly = IsPromotionOnlyBucket(settings, schema); if (promotionOnly) { Debug.LogError("Cannot upload to Promotion Only bucket."); return false; } } return true; } // CcdManagedData.ConfigState.Override means it has been overriden by the customer at build time if (settings.m_CcdManagedData.State == CcdManagedData.ConfigState.Override) { return true; } // Target bucket should only be verified if Automatic profile type settings.m_CcdManagedData.EnvironmentId = ProfileDataSourceSettings.GetSettings().currentEnvironment.id; settings.m_CcdManagedData.EnvironmentName = ProfileDataSourceSettings.GetSettings().currentEnvironment.name; // existing automatic bucket loaded from cache if (existingGroupType != null) { var promotionOnly = IsPromotionOnlyBucket(settings, schema); if (promotionOnly) { Debug.LogError("Cannot upload to Promotion Only bucket."); return false; } settings.m_CcdManagedData.BucketId = existingGroupType .GetVariableBySuffix($"{nameof(CcdBucket)}{nameof(CcdBucket.Id)}").Value; settings.m_CcdManagedData.Badge = existingGroupType .GetVariableBySuffix($"{nameof(CcdBadge)}{nameof(CcdBadge.Name)}").Value; } // otherwise try to create else { try { var api = CcdManagement.Instance; var createdBucket = await CreateManagedBucket(api, ProfileDataSourceSettings.GetSettings().currentEnvironment.id, EditorUserBuildSettings.activeBuildTarget.ToString()); if (createdBucket.Attributes.PromoteOnly) { Debug.LogError("Cannot upload to Promotion Only bucket."); return false; } settings.m_CcdManagedData.BucketId = createdBucket.Id.ToString(); settings.m_CcdManagedData.Badge = "latest"; } catch (Exception e) { Debug.LogError(e.ToString()); return false; } } return true; } /// /// Download addressables_content_state.bin from the CCD managed bucket. /// /// Addressables data builder context /// public async Task DownloadContentStateBin(AddressablesDataBuilderInput input) { if (!input.AddressableSettings.BuildRemoteCatalog) { Debug.LogWarning("Not downloading content state because 'Build Remote Catalog' is not checked in Addressable Asset Settings. This will disable content updates"); return true; } var settings = input.AddressableSettings; var groupType = getRemoteCatalogGroupType(settings); if (groupType == null) { Debug.LogError("Could not find remote catalog paths"); return false; } if (!this.isCCDGroup(groupType)) { Debug.LogError("Content state could not be downloaded as the remote catalog is not targeting CCD"); return false; } try { SetEnvironmentId(settings, groupType); var bucketId = GetBucketId(settings, groupType); if (bucketId == null) { Debug.LogError("Content state could not be downloaded as no bucket was specified. This is populated for managed profiles in the VerifyBucket event."); return false; } var api = CcdManagement.Instance; var ccdEntry = await GetEntryByPath(api, new Guid(bucketId), k_ContentStatePath); if (ccdEntry != null) { var contentStream = await api.GetContentAsync(new EntryOptions(new Guid(bucketId), ccdEntry.Entryid)); var contentStatePath = Path.Combine(settings.GetContentStateBuildPath(), k_ContentStatePath); if (!Directory.Exists(contentStatePath)) Directory.CreateDirectory(Path.GetDirectoryName(contentStatePath)); else if (File.Exists(contentStatePath)) File.Delete(contentStatePath); using (var fileStream = File.Create(contentStatePath)) { contentStream.CopyTo(fileStream); } } } catch (Exception e) { Debug.LogError(e.ToString()); return false; } return true; } /// /// Upload content to the CCD managed bucket and create a release. /// /// Addressables data builder context /// Addressables build result /// public async Task UploadAndRelease(AddressablesDataBuilderInput input, AddressablesPlayerBuildResult result) { // Verify files exist that need uploading var foundRemoteContent = result.FileRegistry?.GetFilePaths() .Any(path => path.StartsWith(AddressableAssetSettings.kCCDBuildDataPath)) == true; if (!foundRemoteContent) { Debug.LogWarning( "Skipping upload and release as no remote content was found to upload. Ensure you have at least one content group's 'Build & Load Path' set to Remote."); return false; } try { //Getting files Debug.Log("Creating and uploading entries"); var startDirectory = new DirectoryInfo(AddressableAssetSettings.kCCDBuildDataPath); var buildData = CreateData(startDirectory); //Creating a release for each bucket var defaultEnvironmentId = ProfileDataSourceSettings.GetSettings().currentEnvironment.id; await UploadAndRelease(CcdManagement.Instance, input.AddressableSettings, defaultEnvironmentId, buildData); } catch (Exception e) { Debug.LogError(e.ToString()); return false; } return true; } private ProfileGroupType getRemoteCatalogGroupType(AddressableAssetSettings settings) { var buildPath = settings.RemoteCatalogBuildPath; var loadPath = settings.RemoteCatalogLoadPath; var groupType = GetGroupType(settings, buildPath, loadPath); return groupType; } /// /// Upload addressables_content_state.bin to the CCD managed bucket. /// /// Addressables data builder context /// Addressables build result /// public async Task UploadContentState(AddressablesDataBuilderInput input, AddressablesPlayerBuildResult result) { if (!input.AddressableSettings.BuildRemoteCatalog) { Debug.LogWarning("Not uploading content state, because 'Build Remote Catalog' is not checked in Addressable Asset Settings. This will disable content updates"); return true; } var settings = input.AddressableSettings; var groupType = getRemoteCatalogGroupType(settings); if (groupType == null) { Debug.LogError("Could not find remote catalog paths"); return false; } if (!this.isCCDGroup(groupType)) { Debug.LogError("Content state could not be uploaded as the remote catalog is not targeting CCD"); return false; } try { SetEnvironmentId(settings, groupType); var bucketId = GetBucketId(settings, groupType); if (bucketId == null) { Debug.LogError("Content state could not be uploaded as no bucket was specified. This is populated for managed profiles in the VerifyBucket event."); return false; } var api = CcdManagement.Instance; var contentStatePath = Path.Combine(settings.GetContentStateBuildPath(), k_ContentStatePath); if (!File.Exists(contentStatePath)) { Debug.LogError($"Content state file is missing {contentStatePath}"); return false; } var contentHash = AddressableAssetUtility.GetMd5Hash(contentStatePath); using (var stream = File.OpenRead(contentStatePath)) { var entryModelOptions = new EntryModelOptions(k_ContentStatePath, contentHash, (int)stream.Length) { UpdateIfExists = true }; var createdEntry = await api.CreateOrUpdateEntryByPathAsync( new EntryByPathOptions(new Guid(bucketId), k_ContentStatePath), entryModelOptions); var uploadContentOptions = new UploadContentOptions( new Guid(bucketId), createdEntry.Entryid, stream); await api.UploadContentAsync(uploadContentOptions); } } catch (Exception e) { Debug.LogError(e.ToString()); return false; } return true; } internal bool IsPromotionOnlyBucket(AddressableAssetSettings settings, BundledAssetGroupSchema schema) { if (schema != null) { var foundGroupType = GetGroupType(settings, schema); if (foundGroupType != null && foundGroupType.GroupTypePrefix.StartsWith("CCD")) { if (bool.Parse(foundGroupType.GetVariableBySuffix(nameof(CcdBucket.Attributes.PromoteOnly)).Value)) { Debug.LogError("Cannot upload to Promotion Only bucket."); return true; } } } return false; } internal ProfileGroupType GetGroupType(AddressableAssetSettings settings, BundledAssetGroupSchema schema) { return GetGroupType(settings, schema.BuildPath, schema.LoadPath); } internal ProfileGroupType GetGroupType(AddressableAssetSettings settings, ProfileValueReference buildPath, ProfileValueReference loadPath) { // we need the "unresolved" value since we're tring to match it to its original type Debug.Log("Loading from active profile id " + settings.activeProfileId); var buildPathValue = settings.profileSettings.GetValueById(settings.activeProfileId, buildPath.Id); var loadPathValue = settings.profileSettings.GetValueById(settings.activeProfileId, loadPath.Id); Debug.Log(loadPathValue); if (buildPathValue == null || loadPathValue == null) { return null; } var groupType = new ProfileGroupType("temp"); groupType.AddVariable(new ProfileGroupType.GroupTypeVariable(AddressableAssetSettings.kBuildPath, buildPathValue)); groupType.AddVariable(new ProfileGroupType.GroupTypeVariable(AddressableAssetSettings.kLoadPath, loadPathValue)); return ProfileDataSourceSettings.GetSettings().FindGroupType(groupType); } internal bool IsUsingManager(AddressableAssetSettings settings, BundledAssetGroupSchema schema) { var groupType = GetGroupType(settings, schema); return IsUsingManager(groupType); } internal bool IsUsingManager(ProfileGroupType groupType) { if (groupType == null) { return false; } return groupType.GroupTypePrefix == AddressableAssetSettings.CcdManagerGroupTypePrefix; } internal void SetEnvironmentId(AddressableAssetSettings settings, ProfileGroupType groupType) { string environmentId = null; if (!IsUsingManager(groupType)) { // if not using the manager load the bucketID from the group type environmentId = groupType.GetVariableBySuffix($"{nameof(ProfileDataSourceSettings.Environment)}{nameof(ProfileDataSourceSettings.Environment.id)}").Value; } else if (settings.m_CcdManagedData != null) { environmentId = settings.m_CcdManagedData.EnvironmentId; } if (environmentId == null) { throw new Exception("unable to determine environment ID."); } CcdManagement.SetEnvironmentId(environmentId); } internal string GetBucketId(AddressableAssetSettings settings, ProfileGroupType groupType) { if (!IsUsingManager(groupType)) { // if not using the manager load the bucketID from the group type return groupType.GetVariableBySuffix($"{nameof(CcdBucket)}{nameof(CcdBucket.Id)}").Value; } if (settings.m_CcdManagedData != null) { return settings.m_CcdManagedData.BucketId; } return null; } static ProfileGroupType GetDefaultGroupType(ProfileDataSourceSettings dataSourceSettings) { //Find existing bucketId var groupTypes = dataSourceSettings.GetGroupTypesByPrefix(string.Join( ProfileGroupType.k_PrefixSeparator.ToString(), "CCD", dataSourceSettings.currentEnvironment.projectGenesisId, dataSourceSettings.currentEnvironment.id)); return groupTypes.FirstOrDefault(gt => gt.GetVariableBySuffix($"{nameof(CcdBucket)}{nameof(CcdBucket.Name)}").Value == EditorUserBuildSettings.activeBuildTarget.ToString()); } async Task CreateManagedBucket(ICcdManagementServiceSdk api, string envId, string bucketName) { CcdBucket ccdBucket; try { CcdManagement.SetEnvironmentId(envId); ccdBucket = await api.CreateBucketAsync( new CreateBucketOptions(bucketName)); } catch (CcdManagementException e) { if (e.ErrorCode == CcdManagementErrorCodes.AlreadyExists) { var buckets = await ProfileDataSourceSettings.GetAllBucketsAsync(envId); ccdBucket = buckets.First(bucket => bucket.Value.Name == EditorUserBuildSettings.activeBuildTarget.ToString()).Value; } else { throw; } } return ccdBucket; } async Task GetEntryByPath(ICcdManagementServiceSdk api, Guid bucketId, string path) { CcdEntry ccdEntry = null; try { ccdEntry = await api.GetEntryByPathAsync(new EntryByPathOptions(bucketId, path)); } catch (CcdManagementException e) { if (e.ErrorCode != CommonErrorCodes.NotFound) { throw; } } return ccdEntry; } CcdBuildDataFolder CreateData(DirectoryInfo startDirectory) { var buildDataFolder = new CcdBuildDataFolder { Name = AddressableAssetSettings.kCCDBuildDataPath, Location = startDirectory.FullName }; buildDataFolder.GetChildren(startDirectory); return buildDataFolder; } int StartProgress(string description) { #if UNITY_2020_1_OR_NEWER return Progress.Start("CCD", description, Progress.Options.Managed); #else Debug.Log(description); return -1; #endif } void RemoveProgress(int progressId) { #if UNITY_2020_1_OR_NEWER Progress.Remove(progressId); #endif } void ReportProgress(int progressId, float progress, string message) { #if UNITY_2020_1_OR_NEWER Progress.Report(progressId, progress, message); #else Debug.Log($"[{progress}] {message}"); #endif } // Do not use Addressable.Log in this method as it may not be executed on the main thread async Task UploadAndRelease(ICcdManagementServiceSdk api, AddressableAssetSettings settings, string defaultEnvironmentId, CcdBuildDataFolder buildData) { var progressId = StartProgress("Upload and Release"); try { foreach (var env in buildData.Environments) { CcdManagement.SetEnvironmentId(env.Name); if (env.Name == ProfileDataSourceSettings.MANAGED_ENVIRONMENT) { CcdManagement.SetEnvironmentId(defaultEnvironmentId); } foreach (var bucket in env.Buckets) { Guid bucketId; var bucketIdString = bucket.Name == ProfileDataSourceSettings.MANAGED_BUCKET ? settings.m_CcdManagedData.BucketId : bucket.Name; if (String.IsNullOrEmpty(bucketIdString)) { Debug.LogError($"Invalid bucket ID for {bucket.Name}"); continue; } bucketId = Guid.Parse(bucketIdString); foreach (var badge in bucket.Badges) { if (badge.Name == ProfileDataSourceSettings.MANAGED_BADGE) { badge.Name = "latest"; } var entries = new List(); var total = badge.Files.Count(); for (var i = 0; i < total; i++) { var file = badge.Files[i]; var contentHash = AddressableAssetUtility.GetMd5Hash(file.FullName); using (var stream = File.OpenRead(file.FullName)) { var entryPath = file.Name; var entryModelOptions = new EntryModelOptions(entryPath, contentHash, (int)stream.Length) { UpdateIfExists = true }; ReportProgress(progressId, (i + 1) / total, $"Creating Entry {entryPath}"); var createdEntry = await api.CreateOrUpdateEntryByPathAsync(new EntryByPathOptions(bucketId, entryPath), entryModelOptions).ConfigureAwait(false); Debug.Log($"Created Entry {entryPath}"); ReportProgress(progressId, (i + 1) / total, $"Uploading Entry {entryPath}"); var uploadContentOptions = new UploadContentOptions(bucketId, createdEntry.Entryid, stream); await api.UploadContentAsync(uploadContentOptions).ConfigureAwait(false); ReportProgress(progressId, (i + 1) / total, $"Uploaded Entry {entryPath}"); entries.Add(new CcdReleaseEntryCreate(createdEntry.Entryid, createdEntry.CurrentVersionid)); } } // Add content_sate.bin to release if present var contentStateEntry = await GetEntryByPath(api, bucketId, k_ContentStatePath); if (contentStateEntry != null) entries.Add(new CcdReleaseEntryCreate(contentStateEntry.Entryid, contentStateEntry.CurrentVersionid)); //Creating release ReportProgress(progressId, total, "Creating release"); Debug.Log("Creating release."); var release = await api.CreateReleaseAsync(new CreateReleaseOptions(bucketId) { Entries = entries, Notes = $"Automated release created for {badge.Name}" }).ConfigureAwait(false); Debug.Log($"Release {release.Releaseid} created."); //Don't update latest badge (as it always updates) if (badge.Name != "latest") { ReportProgress(progressId, total, "Updating badge"); Debug.Log("Updating badge."); var badgeRes = await api.AssignBadgeAsync(new AssignBadgeOptions(bucketId, badge.Name, release.Releaseid)) .ConfigureAwait(false); Debug.Log($"Badge {badgeRes.Name} updated."); } } } } } catch (Exception e) { Debug.LogError(e.ToString()); } finally { RemoveProgress(progressId); } } } } #endif