489 lines
18 KiB
C#
489 lines
18 KiB
C#
|
using System;
|
|||
|
using System.Collections.Generic;
|
|||
|
using System.IO;
|
|||
|
using System.Linq;
|
|||
|
using System.Reflection;
|
|||
|
using System.Text;
|
|||
|
using System.Text.RegularExpressions;
|
|||
|
using UnityEditor;
|
|||
|
using UnityEditor.UIElements;
|
|||
|
using UnityEngine;
|
|||
|
using UnityEngine.UIElements;
|
|||
|
using VRC.PackageManagement.Core.Types.Packages;
|
|||
|
|
|||
|
namespace VRC.PackageManagement.PackageMaker
|
|||
|
{
|
|||
|
public class PackageMakerWindow : EditorWindow
|
|||
|
{
|
|||
|
// VisualElements
|
|||
|
private VisualElement _rootView;
|
|||
|
private TextField _targetAssetFolderField;
|
|||
|
private TextField _packageIDField;
|
|||
|
private Button _actionButton;
|
|||
|
private EnumField _targetVRCPackageField;
|
|||
|
private TextField _authorNameField;
|
|||
|
private TextField _authorEmailField;
|
|||
|
private TextField _authorUrlField;
|
|||
|
private static string _projectDir;
|
|||
|
private PackageMakerWindowData _windowData;
|
|||
|
|
|||
|
private void LoadDataFromSave()
|
|||
|
{
|
|||
|
if (!string.IsNullOrWhiteSpace(_windowData.targetAssetFolder))
|
|||
|
{
|
|||
|
_targetAssetFolderField.SetValueWithoutNotify(_windowData.targetAssetFolder);
|
|||
|
}
|
|||
|
_packageIDField.SetValueWithoutNotify(_windowData.packageID);
|
|||
|
_targetVRCPackageField.SetValueWithoutNotify(_windowData.relatedPackage);
|
|||
|
_authorEmailField.SetValueWithoutNotify(_windowData.authorEmail);
|
|||
|
_authorNameField.SetValueWithoutNotify(_windowData.authorName);
|
|||
|
_authorUrlField.SetValueWithoutNotify(_windowData.authorUrl);
|
|||
|
|
|||
|
RefreshActionButtonState();
|
|||
|
}
|
|||
|
|
|||
|
private void OnEnable()
|
|||
|
{
|
|||
|
_projectDir = Directory.GetParent(Application.dataPath).FullName;
|
|||
|
Refresh();
|
|||
|
}
|
|||
|
|
|||
|
[MenuItem("VRChat SDK/Utilities/Package Maker")]
|
|||
|
public static void ShowWindow()
|
|||
|
{
|
|||
|
PackageMakerWindow wnd = GetWindow<PackageMakerWindow>();
|
|||
|
wnd.titleContent = new GUIContent("Package Maker");
|
|||
|
}
|
|||
|
|
|||
|
[MenuItem("Assets/Export VPM as UnityPackage")]
|
|||
|
private static void ExportAsUnityPackage ()
|
|||
|
{
|
|||
|
|
|||
|
var foldersToExport = new List<string>();
|
|||
|
StringBuilder exportFilename = new StringBuilder("exported");
|
|||
|
foreach (string guid in Selection.assetGUIDs)
|
|||
|
{
|
|||
|
string selectedFolder = AssetDatabase.GUIDToAssetPath(guid);
|
|||
|
var manifestPath = Path.Combine(selectedFolder, VRCPackageManifest.Filename);
|
|||
|
var manifest = VRCPackageManifest.GetManifestAtPath(manifestPath);
|
|||
|
if (manifest == null)
|
|||
|
{
|
|||
|
Debug.LogWarning($"Could not read valid Package Manifest at {manifestPath}. You need to create this first to export a VPM Package.");
|
|||
|
continue;
|
|||
|
}
|
|||
|
exportFilename.Append($"-{manifest.Id}-{manifest.Version}");
|
|||
|
foldersToExport.Add(selectedFolder);
|
|||
|
}
|
|||
|
|
|||
|
exportFilename.Append(".unitypackage");
|
|||
|
var exportDir = Path.Combine(Directory.GetCurrentDirectory(), "Exports");
|
|||
|
Directory.CreateDirectory(exportDir);
|
|||
|
AssetDatabase.ExportPackage
|
|||
|
(
|
|||
|
foldersToExport.ToArray(),
|
|||
|
Path.Combine(exportDir, exportFilename.ToString()),
|
|||
|
ExportPackageOptions.Recurse | ExportPackageOptions.Interactive
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
private void Refresh()
|
|||
|
{
|
|||
|
if (_windowData == null)
|
|||
|
{
|
|||
|
_windowData = PackageMakerWindowData.GetOrCreate();
|
|||
|
}
|
|||
|
|
|||
|
if (_rootView == null) return;
|
|||
|
|
|||
|
if (_windowData != null)
|
|||
|
{
|
|||
|
LoadDataFromSave();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private void RefreshActionButtonState()
|
|||
|
{
|
|||
|
_actionButton.SetEnabled(
|
|||
|
StringIsValidAssetFolder(_windowData.targetAssetFolder) &&
|
|||
|
!string.IsNullOrWhiteSpace(_windowData.packageID) &&
|
|||
|
_authorNameField.value != null &&
|
|||
|
IsValidEmail(_authorEmailField.value)
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
/// <summary>
|
|||
|
/// Unity calls the CreateGUI method automatically when the window needs to display
|
|||
|
/// </summary>
|
|||
|
private void CreateGUI()
|
|||
|
{
|
|||
|
if (_windowData == null)
|
|||
|
{
|
|||
|
_windowData = PackageMakerWindowData.GetOrCreate();
|
|||
|
}
|
|||
|
|
|||
|
_rootView = rootVisualElement;
|
|||
|
_rootView.name = "root-view";
|
|||
|
_rootView.styleSheets.Add((StyleSheet) Resources.Load("PackageMakerWindowStyle"));
|
|||
|
|
|||
|
// Create Target Asset folder and register for drag and drop events
|
|||
|
_rootView.Add(CreateTargetFolderElement());
|
|||
|
_rootView.Add(CreatePackageIDElement());
|
|||
|
_rootView.Add(CreateAuthorElement());
|
|||
|
_rootView.Add(CreateTargetVRCPackageElement());
|
|||
|
_rootView.Add(CreateActionButton());
|
|||
|
|
|||
|
Refresh();
|
|||
|
}
|
|||
|
|
|||
|
public enum VRCPackageEnum
|
|||
|
{
|
|||
|
None = 0,
|
|||
|
Worlds = 1,
|
|||
|
Avatars = 2,
|
|||
|
Base = 3,
|
|||
|
UdonSharp = 4,
|
|||
|
}
|
|||
|
|
|||
|
private VisualElement CreateTargetVRCPackageElement()
|
|||
|
{
|
|||
|
_targetVRCPackageField = new EnumField("Related VRChat Package", VRCPackageEnum.None);
|
|||
|
_targetVRCPackageField.RegisterValueChangedCallback(OnTargetVRCPackageChanged);
|
|||
|
var box = new Box();
|
|||
|
box.Add(_targetVRCPackageField);
|
|||
|
return box;
|
|||
|
}
|
|||
|
|
|||
|
private void OnTargetVRCPackageChanged(ChangeEvent<Enum> evt)
|
|||
|
{
|
|||
|
_windowData.relatedPackage = (VRCPackageEnum)evt.newValue;
|
|||
|
_windowData.Save();
|
|||
|
}
|
|||
|
|
|||
|
private VisualElement CreateActionButton()
|
|||
|
{
|
|||
|
_actionButton = new Button(OnActionButtonPressed)
|
|||
|
{
|
|||
|
text = "Convert Assets to Package",
|
|||
|
name = "action-button"
|
|||
|
};
|
|||
|
return _actionButton;
|
|||
|
}
|
|||
|
|
|||
|
private void OnActionButtonPressed()
|
|||
|
{
|
|||
|
bool result = EditorUtility.DisplayDialog("One-Way Conversion",
|
|||
|
$"This process will move the assets from {_windowData.targetAssetFolder} into a new Package with the id {_windowData.packageID} and give it references to {_windowData.relatedPackage}.",
|
|||
|
"Ok", "Wait, not yet.");
|
|||
|
if (result)
|
|||
|
{
|
|||
|
string newPackageFolderPath = Path.Combine(_projectDir, "Packages", _windowData.packageID);
|
|||
|
Directory.CreateDirectory(newPackageFolderPath);
|
|||
|
var fullTargetAssetFolder = Path.Combine(_projectDir, _windowData.targetAssetFolder);
|
|||
|
DoMigration(fullTargetAssetFolder, newPackageFolderPath);
|
|||
|
ForceRefresh();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
public static void ForceRefresh ()
|
|||
|
{
|
|||
|
MethodInfo method = typeof( UnityEditor.PackageManager.Client ).GetMethod( "Resolve", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly );
|
|||
|
if( method != null )
|
|||
|
method.Invoke( null, null );
|
|||
|
|
|||
|
AssetDatabase.Refresh();
|
|||
|
}
|
|||
|
|
|||
|
private VisualElement CreatePackageIDElement()
|
|||
|
{
|
|||
|
var box = new Box()
|
|||
|
{
|
|||
|
name = "package-name-box"
|
|||
|
};
|
|||
|
|
|||
|
_packageIDField = new TextField("Package ID", 255, false, false, '*');
|
|||
|
_packageIDField.RegisterValueChangedCallback(OnPackageIDChanged);
|
|||
|
box.Add(_packageIDField);
|
|||
|
|
|||
|
box.Add(new Label("Lowercase letters, numbers and dots only.")
|
|||
|
{
|
|||
|
name="description",
|
|||
|
tooltip = "Standard practice is reverse domain notation like com.vrchat.packagename. Needs to be unique across VRChat, so if you don't own a domain you can try your username.",
|
|||
|
});
|
|||
|
|
|||
|
return box;
|
|||
|
}
|
|||
|
|
|||
|
private VisualElement CreateAuthorElement()
|
|||
|
{
|
|||
|
// Construct author fields
|
|||
|
_authorNameField = new TextField("Author Name");
|
|||
|
_authorEmailField = new TextField("Author Email");
|
|||
|
_authorUrlField = new TextField("Author URL (optional)");
|
|||
|
|
|||
|
// Save name to window data and toggle the Action Button if its status changed
|
|||
|
_authorNameField.RegisterValueChangedCallback((evt) =>
|
|||
|
{
|
|||
|
_windowData.authorName = evt.newValue;
|
|||
|
Debug.Log($"Window author name is {evt.newValue}");
|
|||
|
RefreshActionButtonState();
|
|||
|
});
|
|||
|
|
|||
|
// Save email to window data if valid and toggle the Action Button if its status changed
|
|||
|
_authorEmailField.RegisterValueChangedCallback((evt) =>
|
|||
|
{
|
|||
|
// Only save email if it appears valid
|
|||
|
if (IsValidEmail(evt.newValue))
|
|||
|
{
|
|||
|
_windowData.authorEmail = evt.newValue;
|
|||
|
}
|
|||
|
RefreshActionButtonState();
|
|||
|
});
|
|||
|
|
|||
|
// Save url to window data, doesn't affect action button state
|
|||
|
_authorUrlField.RegisterValueChangedCallback((evt) =>
|
|||
|
{
|
|||
|
_windowData.authorUrl = evt.newValue;
|
|||
|
});
|
|||
|
|
|||
|
// Add new fields to layout
|
|||
|
var box = new Box();
|
|||
|
box.Add(_authorNameField);
|
|||
|
box.Add(_authorEmailField);
|
|||
|
box.Add(_authorUrlField);
|
|||
|
return box;
|
|||
|
}
|
|||
|
|
|||
|
private bool IsValidEmail(string evtNewValue)
|
|||
|
{
|
|||
|
try
|
|||
|
{
|
|||
|
var addr = new System.Net.Mail.MailAddress(evtNewValue);
|
|||
|
return addr.Address == evtNewValue;
|
|||
|
}
|
|||
|
catch
|
|||
|
{
|
|||
|
return false;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private Regex packageIdRegex = new Regex("[^a-z0-9.]");
|
|||
|
private void OnPackageIDChanged(ChangeEvent<string> evt)
|
|||
|
{
|
|||
|
if (evt.newValue != null)
|
|||
|
{
|
|||
|
string newId = packageIdRegex.Replace(evt.newValue, "-");
|
|||
|
_packageIDField.SetValueWithoutNotify(newId);
|
|||
|
_windowData.packageID = newId;
|
|||
|
_windowData.Save();
|
|||
|
}
|
|||
|
RefreshActionButtonState();
|
|||
|
}
|
|||
|
|
|||
|
private VisualElement CreateTargetFolderElement()
|
|||
|
{
|
|||
|
var targetFolderBox = new Box()
|
|||
|
{
|
|||
|
name = "editor-target-box"
|
|||
|
};
|
|||
|
|
|||
|
_targetAssetFolderField = new TextField("Target Folder");
|
|||
|
_targetAssetFolderField.RegisterCallback<DragEnterEvent>(OnTargetAssetFolderDragEnter, TrickleDown.TrickleDown);
|
|||
|
_targetAssetFolderField.RegisterCallback<DragLeaveEvent>(OnTargetAssetFolderDragLeave, TrickleDown.TrickleDown);
|
|||
|
_targetAssetFolderField.RegisterCallback<DragUpdatedEvent>(OnTargetAssetFolderDragUpdated, TrickleDown.TrickleDown);
|
|||
|
_targetAssetFolderField.RegisterCallback<DragPerformEvent>(OnTargetAssetFolderDragPerform, TrickleDown.TrickleDown);
|
|||
|
_targetAssetFolderField.RegisterCallback<DragExitedEvent>(OnTargetAssetFolderDragExited, TrickleDown.TrickleDown);
|
|||
|
_targetAssetFolderField.RegisterValueChangedCallback(OnTargetAssetFolderValueChanged);
|
|||
|
targetFolderBox.Add(_targetAssetFolderField);
|
|||
|
|
|||
|
targetFolderBox.Add(new Label("Drag and Drop an Assets Folder to Convert Above"){name="description"});
|
|||
|
return targetFolderBox;
|
|||
|
}
|
|||
|
|
|||
|
#region TargetAssetFolder Field Events
|
|||
|
|
|||
|
private bool StringIsValidAssetFolder(string targetFolder)
|
|||
|
{
|
|||
|
return !string.IsNullOrWhiteSpace(targetFolder) && AssetDatabase.IsValidFolder(targetFolder);
|
|||
|
}
|
|||
|
|
|||
|
private void OnTargetAssetFolderValueChanged(ChangeEvent<string> evt)
|
|||
|
{
|
|||
|
string targetFolder = evt.newValue;
|
|||
|
|
|||
|
if (StringIsValidAssetFolder(targetFolder))
|
|||
|
{
|
|||
|
_windowData.targetAssetFolder = evt.newValue;
|
|||
|
_windowData.Save();
|
|||
|
RefreshActionButtonState();
|
|||
|
}
|
|||
|
else
|
|||
|
{
|
|||
|
_targetAssetFolderField.SetValueWithoutNotify(evt.previousValue);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private void OnTargetAssetFolderDragExited(DragExitedEvent evt)
|
|||
|
{
|
|||
|
DragAndDrop.visualMode = DragAndDropVisualMode.None;
|
|||
|
}
|
|||
|
|
|||
|
private void OnTargetAssetFolderDragPerform(DragPerformEvent evt)
|
|||
|
{
|
|||
|
var targetFolder = DragAndDrop.paths[0];
|
|||
|
if (!string.IsNullOrWhiteSpace(targetFolder) && AssetDatabase.IsValidFolder(targetFolder))
|
|||
|
{
|
|||
|
_targetAssetFolderField.value = targetFolder;
|
|||
|
}
|
|||
|
else
|
|||
|
{
|
|||
|
Debug.LogError($"Could not accept {targetFolder}. Needs to be a folder within the project");
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private void OnTargetAssetFolderDragUpdated(DragUpdatedEvent evt)
|
|||
|
{
|
|||
|
if (DragAndDrop.paths.Length == 1)
|
|||
|
{
|
|||
|
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
|||
|
DragAndDrop.AcceptDrag();
|
|||
|
}
|
|||
|
else
|
|||
|
{
|
|||
|
DragAndDrop.visualMode = DragAndDropVisualMode.Rejected;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private void OnTargetAssetFolderDragLeave(DragLeaveEvent evt)
|
|||
|
{
|
|||
|
DragAndDrop.visualMode = DragAndDropVisualMode.None;
|
|||
|
}
|
|||
|
|
|||
|
private void OnTargetAssetFolderDragEnter(DragEnterEvent evt)
|
|||
|
{
|
|||
|
if (DragAndDrop.paths.Length == 1)
|
|||
|
{
|
|||
|
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
|||
|
DragAndDrop.AcceptDrag();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
#endregion
|
|||
|
|
|||
|
#region Migration Logic
|
|||
|
|
|||
|
private void DoMigration(string corePath, string targetDir)
|
|||
|
{
|
|||
|
|
|||
|
EditorUtility.DisplayProgressBar("Migrating Package", "Creating Starter Package", 0.1f);
|
|||
|
|
|||
|
// Convert PackageType enum to VRC Package ID string
|
|||
|
string packageType = null;
|
|||
|
switch (_windowData.relatedPackage)
|
|||
|
{
|
|||
|
case VRCPackageEnum.Avatars:
|
|||
|
packageType = "com.vrchat.avatars";
|
|||
|
break;
|
|||
|
case VRCPackageEnum.Base:
|
|||
|
packageType = "com.vrchat.base";
|
|||
|
break;
|
|||
|
case VRCPackageEnum.Worlds:
|
|||
|
packageType = "com.vrchat.clientsim"; // we want ClientSim too, need to specify that for now
|
|||
|
break;
|
|||
|
case VRCPackageEnum.UdonSharp:
|
|||
|
packageType = "com.vrchat.udonsharp";
|
|||
|
break;
|
|||
|
}
|
|||
|
|
|||
|
string parentDir = new DirectoryInfo(targetDir)?.Parent.FullName;
|
|||
|
var packageDir = Core.Utilities.CreateStarterPackage(_windowData.packageID, parentDir, packageType);
|
|||
|
|
|||
|
// Modify manifest to add author
|
|||
|
// Todo: add support for passing author into CreateStarterPackage
|
|||
|
var manifest =
|
|||
|
VRCPackageManifest.GetManifestAtPath(Path.Combine(packageDir, VRCPackageManifest.Filename)) as
|
|||
|
VRCPackageManifest;
|
|||
|
manifest.author = new Author()
|
|||
|
{
|
|||
|
email = _windowData.authorEmail,
|
|||
|
name = _windowData.authorName,
|
|||
|
url = _windowData.authorUrl
|
|||
|
};
|
|||
|
manifest.Save();
|
|||
|
|
|||
|
var allFiles = GetAllFiles(corePath).ToList();
|
|||
|
MoveFilesToPackageDir(allFiles, corePath, targetDir);
|
|||
|
|
|||
|
// Clear target asset folder since it should no longer exist
|
|||
|
_windowData.targetAssetFolder = "";
|
|||
|
}
|
|||
|
|
|||
|
private static IEnumerable<string> GetAllFiles(string path)
|
|||
|
{
|
|||
|
var excludedPaths = new List<string>()
|
|||
|
{
|
|||
|
"Editor.meta"
|
|||
|
};
|
|||
|
return Directory.EnumerateFiles(path, "*.*", SearchOption.AllDirectories)
|
|||
|
.Where(
|
|||
|
s => excludedPaths.All(entry => !s.Contains(entry))
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
public static void MoveFilesToPackageDir(List<string> files, string pathBase, string targetDir)
|
|||
|
{
|
|||
|
EditorUtility.DisplayProgressBar("Migrating Package", "Moving Package Files", 0f);
|
|||
|
float totalFiles = files.Count;
|
|||
|
|
|||
|
for (int i = 0; i < files.Count; i++)
|
|||
|
{
|
|||
|
try
|
|||
|
{
|
|||
|
EditorUtility.DisplayProgressBar("Migrating Package", "Moving Package Files", i / totalFiles);
|
|||
|
var file = files[i];
|
|||
|
string simplifiedPath = file.Replace($"{pathBase}\\", "");
|
|||
|
|
|||
|
string dest = null;
|
|||
|
if (simplifiedPath.Contains("Editor\\"))
|
|||
|
{
|
|||
|
// Remove extra 'Editor' subfolders
|
|||
|
dest = simplifiedPath.Replace("Editor\\", "");
|
|||
|
dest = Path.Combine(targetDir, "Editor", dest);
|
|||
|
}
|
|||
|
else
|
|||
|
{
|
|||
|
// Make complete path to Runtime folder
|
|||
|
dest = Path.Combine(targetDir, "Runtime", simplifiedPath);
|
|||
|
}
|
|||
|
|
|||
|
string targetEnclosingDir = Path.GetDirectoryName(dest);
|
|||
|
Directory.CreateDirectory(targetEnclosingDir);
|
|||
|
var sourceFile = Path.Combine(pathBase, simplifiedPath);
|
|||
|
File.Move(sourceFile, dest);
|
|||
|
}
|
|||
|
catch (Exception e)
|
|||
|
{
|
|||
|
Debug.LogError($"Error moving {files[i]}: {e.Message}");
|
|||
|
continue;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
Directory.Delete(pathBase, true); // cleans up leftover folders since only files are moved
|
|||
|
EditorUtility.ClearProgressBar();
|
|||
|
}
|
|||
|
|
|||
|
// Important while we're doing copy-and-rename in order to rename paths with "Assets" without renaming paths with "Sample Assets"
|
|||
|
public static string ReplaceFirst(string text, string search, string replace)
|
|||
|
{
|
|||
|
int pos = text.IndexOf(search);
|
|||
|
if (pos < 0)
|
|||
|
{
|
|||
|
return text;
|
|||
|
}
|
|||
|
|
|||
|
return text.Substring(0, pos) + replace + text.Substring(pos + search.Length);
|
|||
|
}
|
|||
|
|
|||
|
#endregion
|
|||
|
}
|
|||
|
|
|||
|
}
|