Files
2025-07-21 09:11:14 +02:00

556 lines
20 KiB
C#

/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* All rights reserved.
*
* Licensed under the Oculus SDK License Agreement (the "License");
* you may not use the Oculus SDK except in compliance with the License,
* which is provided at the time of installation or download, or which
* otherwise accompanies this software in either electronic or hard copy form.
*
* You may obtain a copy of the License at
*
* https://developer.oculus.com/licenses/oculussdk/
*
* Unless required by applicable law or agreed to in writing, the Oculus SDK
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using UnityEditor.PackageManager;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace Oculus.Interaction.Editor
{
[InitializeOnLoad]
public static class PackageCleanup
{
private enum CleanupOperation
{
None,
Delete,
Move,
StripTags,
}
private class CleanupInfo
{
public CleanupOperation Operation;
public GUID AssetGuid;
public GUID MoveToPathGuid;
}
private enum CleanupResult
{
None,
Success,
Cancel,
Incomplete,
}
public const string PACKAGE_VERSION = "0.57.0";
public const string DEPRECATED_TAG = "oculus_interaction_deprecated";
public const string MOVED_TAG = "oculus_interaction_moved_";
private const string MENU_NAME = "Oculus/Interaction/Clean Up Package";
private const string AUTO_CLEANUP_KEY = "Oculus_Interaction_AutoCleanUp_" + PACKAGE_VERSION;
private static bool AutoCleanup
{
get => PlayerPrefs.GetInt(AUTO_CLEANUP_KEY, 1) == 1;
set => PlayerPrefs.SetInt(AUTO_CLEANUP_KEY, value ? 1 : 0);
}
static PackageCleanup()
{
EditorApplication.delayCall += HandleDelayCall;
}
[MenuItem(MENU_NAME)]
private static void AssetRemovalMenuCommand()
{
AutoCleanup = true;
StartRemovalUserFlow(true);
}
private static void HandleDelayCall()
{
bool startAutoDeprecation = !Application.isBatchMode &&
AutoCleanup &&
!Application.isPlaying;
if (startAutoDeprecation)
{
StartRemovalUserFlow(false);
}
}
/// <summary>
/// Check if there are any assets in the project that require
/// cleanup operations.
/// </summary>
/// <returns>True if package needs cleanup</returns>
public static bool CheckPackageNeedsCleanup()
{
return GetAssetInfos().Count > 0;
}
/// <summary>
/// Start the removal flow for removing deprecated assets.
/// </summary>
/// <param name="userTriggered">If true, the window will
/// be non-modal, and a dialog will be shown if no assets found</param>
public static void StartRemovalUserFlow(bool userTriggered)
{
var assetInfos = GetAssetInfos();
if (assetInfos.Count == 0)
{
if (userTriggered)
{
EditorUtility.DisplayDialog("Interaction SDK",
"No clean up needed in package.", "Close");
}
else
{
return;
}
}
else
{
int deletionPromptResult = EditorUtility.DisplayDialogComplex(
"Interaction SDK",
"This utility performs a cleanup operation which relocates " +
"Interaction SDK files and folders, and removes asset stubs provided " +
"for backwards compatibility during package upgrade." +
"\n\n" +
"Click 'Show Assets' to view a list of the assets to be modified. " +
"You will then be given the option to run the cleanup operation on them.",
"Show Assets (Recommended)", "No, Don't Ask Again", "No");
switch (deletionPromptResult)
{
case 0: // "Yes"
bool modalWindow = !userTriggered;
ShowAssetCleanupWindow(assetInfos, modalWindow);
break;
case 1: // "No, Don't Ask Again"
AutoCleanup = false;
ShowCancelDialog();
break;
default:
case 2: // "No"
AutoCleanup = true;
break;
}
}
}
private static IReadOnlyList<CleanupInfo> GetAssetInfos()
{
List<CleanupInfo> result = new List<CleanupInfo>();
var deprecatedGUIDs = AssetDatabase.FindAssets($"l:{DEPRECATED_TAG}", null)
.Select((guidStr) => new GUID(guidStr));
var movedGUIDs = AssetDatabase.FindAssets($"l:{MOVED_TAG}", null)
.Select((guidStr) => new GUID(guidStr));
foreach (var GUID in deprecatedGUIDs)
{
result.Add(new CleanupInfo()
{
Operation = CleanupOperation.Delete,
AssetGuid = GUID,
});
}
foreach (var GUID in movedGUIDs)
{
if (GetDestFolderForMovedAsset(GUID, out GUID newPathGUID))
{
result.Add(new CleanupInfo()
{
Operation = CleanupOperation.Move,
AssetGuid = GUID,
MoveToPathGuid = newPathGUID,
});
}
else
{
result.Add(new CleanupInfo()
{
Operation = CleanupOperation.StripTags,
AssetGuid = GUID,
});
}
}
result.RemoveAll((info) =>
{
// Ignore assets in read-only packages
var pSource = PackageInfo.FindForAssetPath(
AssetDatabase.GUIDToAssetPath(info.AssetGuid))?.source;
return pSource != null && // In Assets folder
pSource != PackageSource.Embedded &&
pSource != PackageSource.Local;
});
return result;
}
private static void ShowAssetCleanupWindow(
IEnumerable<CleanupInfo> cleanupInfos, bool modal)
{
void DrawHeader(AssetListWindow window)
{
EditorGUILayout.HelpBox(
"Assets marked Delete will be permanently deleted",
MessageType.Warning);
}
void DrawFooter(AssetListWindow window)
{
GUILayoutOption buttonHeight = GUILayout.Height(36);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Clean Up (Recommended)", buttonHeight))
{
var result = CleanUpAssets(cleanupInfos);
switch (result)
{
default:
case CleanupResult.None:
case CleanupResult.Cancel:
AutoCleanup = true;
break;
case CleanupResult.Success:
case CleanupResult.Incomplete:
AutoCleanup = false;
window.Close();
break;
}
}
if (GUILayout.Button("Cancel", buttonHeight))
{
ShowCancelDialog();
}
EditorGUILayout.EndHorizontal();
}
List<AssetListWindow.AssetInfo> windowInfos =
new List<AssetListWindow.AssetInfo>();
foreach (var info in cleanupInfos)
{
switch (info.Operation)
{
default:
case CleanupOperation.None:
break;
case CleanupOperation.Delete:
windowInfos.Add(new AssetListWindow.AssetInfo(
GUIDToAssetPath(info.AssetGuid),
$"<color=orange>Delete:</color> " +
$"{GUIDToAssetPath(info.AssetGuid)}"));
break;
case CleanupOperation.Move:
windowInfos.Add(new AssetListWindow.AssetInfo(
GUIDToAssetPath(info.AssetGuid),
$"<color=yellow>Move:</color> " +
$"{GUIDToAssetPath(info.AssetGuid)} -> " +
$"{GUIDToAssetPath(info.MoveToPathGuid)}"));
break;
case CleanupOperation.StripTags:
windowInfos.Add(new AssetListWindow.AssetInfo(
GUIDToAssetPath(info.AssetGuid),
$"<color=lime>Unlabel:</color> " +
$"{GUIDToAssetPath(info.AssetGuid)}"));
break;
}
}
AssetListWindow assetListWindow = AssetListWindow.Show(
"Interaction SDK - All Assets to be Modified",
windowInfos, modal, DrawHeader, DrawFooter);
}
private static void ShowCancelDialog()
{
AssetListWindow.CloseAll();
EditorUtility.DisplayDialog("Interaction SDK",
$"Package cleanup was not run. " +
$"You can run this utility at any time " +
$"using the '{MENU_NAME}' menu.",
"Close");
}
private static bool GetDestFolderForMovedAsset(GUID assetGUID, out GUID destFolderGUID)
{
destFolderGUID = new GUID();
Object assetObject = AssetDatabase.LoadMainAssetAtPath(GUIDToAssetPath(assetGUID));
List<string> labels = new List<string>(AssetDatabase.GetLabels(assetObject));
int index = labels.FindIndex((l) => l.Contains(MOVED_TAG));
if (index >= 0)
{
destFolderGUID = new GUID(labels[index].Remove(0, MOVED_TAG.Length));
// Verify that paths exist, and new path is not the same as old path
string curPath = Path.GetFullPath(GUIDToAssetPath(assetGUID));
string newFolder = Path.GetFullPath(GUIDToAssetPath(destFolderGUID));
string targetFilePath = Path.Combine(newFolder, Path.GetFileName(curPath));
if (!curPath.Equals(targetFilePath) &&
(Directory.Exists(curPath) || File.Exists(curPath)) &&
Directory.Exists(newFolder))
{
return true;
}
}
return false;
}
private static CleanupResult CleanUpAssets(IEnumerable<CleanupInfo> cleanupInfos)
{
if (EditorUtility.DisplayDialog("Are you sure?",
"Any assets marked for deletion will be permanently deleted." +
"\n\n" +
"It is strongly recommended that you back up your project before proceeding.",
"Clean Up Package", "Cancel"))
{
var deletions = new List<GUID>();
var moves = new Dictionary<GUID, GUID>();
var stripTags = new List<GUID>();
foreach (var info in cleanupInfos)
{
switch (info.Operation)
{
default:
case CleanupOperation.None:
break;
case CleanupOperation.Delete:
deletions.Add(info.AssetGuid);
break;
case CleanupOperation.Move:
moves.Add(info.AssetGuid, info.MoveToPathGuid);
break;
case CleanupOperation.StripTags:
stripTags.Add(info.AssetGuid);
break;
}
}
bool result = true;
result &= MoveAssets(moves);
result &= DeleteAssets(deletions);
result &= StripTags(stripTags);
return result ? CleanupResult.Success : CleanupResult.Incomplete;
}
else
{
return CleanupResult.Cancel;
}
}
private static bool MoveAssets(IDictionary<GUID, GUID> curToNewPathGUID)
{
Dictionary<string, string> moves = new Dictionary<string, string>();
Dictionary<string, string> failures = new Dictionary<string, string>();
foreach (var assetGUID in curToNewPathGUID.Keys)
{
if (!curToNewPathGUID.TryGetValue(assetGUID, out GUID newPathGUID))
{
string failedPath = GUIDToAssetPath(assetGUID);
failures.Add(failedPath, $"No new path provided for asset {failedPath}");
continue;
}
string curPath = GUIDToAssetPath(assetGUID);
string newPath = Path.Combine(GUIDToAssetPath(newPathGUID),
Path.GetFileName(curPath));
if (Path.GetFullPath(curPath).Equals(Path.GetFullPath(newPath)))
{
// Source and destination paths already match
continue;
}
string result = AssetDatabase.MoveAsset(curPath, newPath);
if (!string.IsNullOrEmpty(result))
{
failures.Add(curPath, result);
}
else
{
// Strip labels after successful move
StripTag(assetGUID, MOVED_TAG);
moves.Add(curPath, newPath);
}
}
string logMessage;
if (BuildLogMessage("Assets moved:",
moves.Keys.Select((key) => $"{key} -> {moves[key]}"),
out logMessage))
{
Debug.Log(logMessage);
}
if (BuildLogMessage("Could not move assets:",
failures.Keys.Select((key) => $"{key}:{failures[key]}"),
out logMessage))
{
Debug.LogError(logMessage);
}
return failures.Count == 0;
}
private static bool DeleteAssets(IEnumerable<GUID> assetGUIDs)
{
var assetPaths = assetGUIDs
.Select((guid) => GUIDToAssetPath(guid));
HashSet<string> filesToDelete = new HashSet<string>();
HashSet<string> foldersToDelete = new HashSet<string>();
HashSet<string> skippedFolders = new HashSet<string>();
HashSet<string> failedPaths = new HashSet<string>();
foreach (var path in assetPaths)
{
if (File.Exists(path))
{
filesToDelete.Add(path);
}
else if (Directory.Exists(path))
{
foldersToDelete.Add(path);
}
else
{
failedPaths.Add(path);
}
}
#if UNITY_2020_1_OR_NEWER
List<string> failed = new List<string>();
// Delete files
AssetDatabase.DeleteAssets(filesToDelete.ToArray(), failed);
failedPaths.UnionWith(failed);
// Remove non-empty folders from delete list
skippedFolders.UnionWith(foldersToDelete
.Where((path) => AssetDatabase.FindAssets("", new[] { path })
.Select((guid) => AssetDatabase.GUIDToAssetPath(guid))
.Any((path) => !AssetDatabase.IsValidFolder(path))));
foldersToDelete.ExceptWith(skippedFolders);
// Delete folders, removing longest paths (subfolders) first
List<string> sortedFolders = new List<string>(foldersToDelete);
sortedFolders.Sort((a, b) => b.Length.CompareTo(a.Length));
AssetDatabase.DeleteAssets(sortedFolders.ToArray(), failed);
failedPaths.UnionWith(failed);
#else
// Delete files
foreach (var path in filesToDelete)
{
if (!AssetDatabase.DeleteAsset(path))
{
failedPaths.Add(path);
}
}
// Remove non-empty folders from delete list
skippedFolders.UnionWith(foldersToDelete
.Where((path) => Directory.EnumerateFiles(path).Any()));
foldersToDelete.ExceptWith(skippedFolders);
// Delete folders
foreach (var path in foldersToDelete)
{
if (!AssetDatabase.DeleteAsset(path))
{
failedPaths.Add(path);
}
}
#endif
string logMessage;
if (BuildLogMessage("Deprecated assets deleted:",
filesToDelete.Union(foldersToDelete), out logMessage))
{
Debug.Log(logMessage);
}
if (BuildLogMessage("Skipped non-empty folders:",
skippedFolders, out logMessage))
{
Debug.LogWarning(logMessage);
}
if (BuildLogMessage("Failed to delete assets:",
failedPaths, out logMessage))
{
Debug.LogError(logMessage);
}
return failedPaths.Count == 0;
}
private static bool StripTags(IEnumerable<GUID> assetGUIDs)
{
foreach (var GUID in assetGUIDs)
{
StripTag(GUID, DEPRECATED_TAG);
StripTag(GUID, MOVED_TAG);
}
return true;
}
private static void StripTag(in GUID assetGUID, string tag)
{
string assetPath = GUIDToAssetPath(assetGUID);
Object assetObject = AssetDatabase.LoadMainAssetAtPath(assetPath);
List<string> labels = new List<string>(AssetDatabase.GetLabels(assetObject));
labels.RemoveAll((l) => l.Contains(tag));
AssetDatabase.SetLabels(assetObject, labels.ToArray());
}
private static bool BuildLogMessage(
string title,
IEnumerable<string> messages,
out string message)
{
int count = 0;
StringBuilder sb = new StringBuilder();
sb.Append(title);
foreach (var msg in messages)
{
sb.Append(System.Environment.NewLine);
sb.Append(msg);
++count;
}
message = sb.ToString();
return count > 0;
}
private static string GUIDToAssetPath(GUID guid)
{
#if UNITY_2020_3_OR_NEWER
return AssetDatabase.GUIDToAssetPath(guid);
#else
return AssetDatabase.GUIDToAssetPath(guid.ToString());
#endif
}
}
}