aboutsummaryrefslogtreecommitdiff
path: root/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker
diff options
context:
space:
mode:
authorAlex Pooley (@zuedev) <zuedev@gmail.com>2026-06-11 23:50:51 +0100
committerAlex Pooley (@zuedev) <zuedev@gmail.com>2026-06-11 23:50:51 +0100
commit313604645ca43aeed156e19d3d8b7d0e5d394f3d (patch)
treeb675de907eedf1e4f5bce24aff570460017587ae /Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker
parenta08432bcb9f28f06887fbdfac24b5757afeaf89f (diff)
downloadVRCog-313604645ca43aeed156e19d3d8b7d0e5d394f3d.tar
VRCog-313604645ca43aeed156e19d3d8b7d0e5d394f3d.tar.gz
VRCog-313604645ca43aeed156e19d3d8b7d0e5d394f3d.tar.bz2
VRCog-313604645ca43aeed156e19d3d8b7d0e5d394f3d.tar.xz
VRCog-313604645ca43aeed156e19d3d8b7d0e5d394f3d.zip
cleanup
Diffstat (limited to 'Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker')
-rw-r--r--Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs488
-rw-r--r--Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs.meta11
-rw-r--r--Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs48
-rw-r--r--Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs.meta11
4 files changed, 558 insertions, 0 deletions
diff --git a/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs
new file mode 100644
index 0000000..4edcbc3
--- /dev/null
+++ b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs
@@ -0,0 +1,488 @@
+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();
+ }
+
+ ScrollView scrollView = new();
+ rootVisualElement.Add(scrollView);
+
+ _rootView = scrollView;
+ _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
+ }
+
+ 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.worlds";
+ 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
+ }
+
+} \ No newline at end of file
diff --git a/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs.meta b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs.meta
new file mode 100644
index 0000000..e2b9647
--- /dev/null
+++ b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindow.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d75fcaecb8b9e7f4bbe783e5f4c9838a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs
new file mode 100644
index 0000000..df8eef6
--- /dev/null
+++ b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs
@@ -0,0 +1,48 @@
+using System.IO;
+using UnityEditor;
+using UnityEngine;
+using VRC.PackageManagement.PackageMaker;
+
+public class PackageMakerWindowData : ScriptableObject
+{
+ public static string defaultAssetPath = Path.Combine("Assets", "PackageMakerWindowData.asset");
+ public string targetAssetFolder;
+ public string packageID;
+
+ public string authorName;
+ public string authorEmail;
+ public string authorUrl;
+ public PackageMakerWindow.VRCPackageEnum relatedPackage;
+
+ public static PackageMakerWindowData GetOrCreate()
+ {
+ var existingData = AssetDatabase.AssetPathToGUID(defaultAssetPath);
+ if (string.IsNullOrWhiteSpace(existingData))
+ {
+ return Create();
+ }
+ else
+ {
+ var saveData = AssetDatabase.LoadAssetAtPath<PackageMakerWindowData>(defaultAssetPath);
+ if (saveData == null)
+ {
+ Debug.LogError($"Could not load saved data but the save file exists. Resetting.");
+ return Create();
+ }
+ return saveData;
+ }
+ }
+
+ public static PackageMakerWindowData Create()
+ {
+ var saveData = CreateInstance<PackageMakerWindowData>();
+ AssetDatabase.CreateAsset(saveData, defaultAssetPath);
+ AssetDatabase.SaveAssets();
+ return saveData;
+ }
+
+ public void Save()
+ {
+ AssetDatabase.SaveAssets();
+ }
+}
diff --git a/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs.meta b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs.meta
new file mode 100644
index 0000000..da310c3
--- /dev/null
+++ b/Packages/com.vrchat.core.vpm-resolver/Editor/PackageMaker/PackageMakerWindowData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0adae93375f5d5840a30b6e47f324172
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: