Files
Silly-Home/Packages/com.vrchat.base/Integrations/VRChat Example Central/Editor/Example Downloader Panel/ExampleDownloaderPanel.cs
2026-06-07 16:58:24 +01:00

370 lines
14 KiB
C#

using System;
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Algolia.Search.Clients;
using Algolia.Search.Models.Search;
using UnityEditor.UIElements;
using VRC.Core;
using AlgoliaPackage = VRC.ExampleCentral.Types.Algolia.UnityPackage;
using Button = UnityEngine.UIElements.Button;
#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
namespace VRC.ExampleCentral.Window
{
public class ExampleDownloaderPanel : EditorWindow
{
[SerializeField] private VisualTreeAsset VisualTree;
// Visual Elements, cached after creation
private VisualElement goToLoginView;
private VisualElement mainView;
private VisualElement previewThumbnail;
private Label previewTitle;
private Label previewTags;
private Label previewVersion;
private Label previewDescription;
private Button previewDownloadButton;
private Button previewDocsButtons;
private ToolbarSearchField searchField;
private VisualElement examplesScrollviewContainer;
private Button settingsButton;
// Searching
private CancellationTokenSource typingCancellation;
private int searchDelayMs = 500;
private AlgoliaPackage selectedPackage;
private PackageButton selectedButton;
// Algolia Info
public const string AlgoliaSearchKey = "60b185254097bf630e913ceaf103822a";
public const string AlgoliaAppKey = "P787M8AJR8";
public const string AlgoliaIndexName = "unity-packages";
// Analytics rate-limiting
private const int EVENT_LIMIT_EXAMPLE_PREVIEWED = 10;
private const int TIME_LIMIT_EXAMPLE_PREVIEWED = 60;
private int eventCounterExamplePreviewed;
private DateTime lastResetTimeExamplePreviewed = DateTime.Now;
#region Setup and Basics
[MenuItem("VRChat SDK/🏠 Example Central", false, 980)]
public static void ShowWindow()
{
ExampleDownloaderPanel window = GetWindow<ExampleDownloaderPanel>();
window.titleContent = new GUIContent("VRChat Example Central");
window.Show();
AnalyticsSDK.ExampleCentralOpened();
}
private void CreateGUI()
{
// Set up base layout
VisualTree.CloneTree(rootVisualElement);
// Get top-level UI containers
goToLoginView = rootVisualElement.Query<VisualElement>("go-to-login");
mainView = rootVisualElement.Query<VisualElement>("split-main");
// Get Auth View elements
Button goToLoginButton = goToLoginView.Query<Button>("go-to-login-btn");
// If the user is already logged in, enable main view
if (APIUser.IsLoggedIn)
OnUserLoggedIn(null, null);
// Get Listing elements
searchField = rootVisualElement.Query<ToolbarSearchField>("examples-search-field");
searchField.RegisterValueChangedCallback(CheckSearch);
// Get Example elements
examplesScrollviewContainer = rootVisualElement.Query<VisualElement>("examples-scrollview-container");
examplesScrollviewContainer.Clear();
// Get Preview elements
previewThumbnail = rootVisualElement.Query<VisualElement>("preview-thumbnail");
previewTitle = rootVisualElement.Query<Label>("preview-title");
previewTags = rootVisualElement.Query<Label>("preview-tags");
previewVersion = rootVisualElement.Query<Label>("preview-version");
previewDescription = rootVisualElement.Query<Label>("preview-description");
previewDownloadButton = rootVisualElement.Query<Button>("preview-download");
previewDocsButtons = rootVisualElement.Query<Button>("preview-documentation");
previewDownloadButton.clicked += DownloadSelectedPackage;
previewDocsButtons.clicked += OpenSelectedPackageDocs;
goToLoginButton.clicked += OpenControlPanel;
VRCSdkControlPanel.OnPanelLoggedIn += OnUserLoggedIn;
VRCSdkControlPanel.OnPanelLoggedOut += OnUserLoggedOut;
settingsButton = rootVisualElement.Query<Button>("settings-button");
var icon = Resources.Load<Texture2D>("gear");
settingsButton.style.backgroundImage = new StyleBackground(icon);
settingsButton.clicked += ExampleCentralSettings.OpenSettings;
// Set the default preview.
DemoPreview();
// Fetch and display UnityPackages
UpdatePackagesAsync();
}
private void OnDestroy()
{
previewDownloadButton.clicked -= DownloadSelectedPackage;
previewDocsButtons.clicked -= OpenSelectedPackageDocs;
VRCSdkControlPanel.OnPanelLoggedIn -= OnUserLoggedIn;
VRCSdkControlPanel.OnPanelLoggedOut -= OnUserLoggedOut;
}
private static void OpenControlPanel() => VRCSdkControlPanel.ShowControlPanel();
private void OnUserLoggedIn(object _, APIUser __) => ToggleView(true);
private void OnUserLoggedOut(object _, EventArgs __) => ToggleView(false);
private void ToggleView(bool isUserLoggedIn)
{
goToLoginView.style.display = isUserLoggedIn ? DisplayStyle.None : DisplayStyle.Flex;
mainView.style.display = isUserLoggedIn ? DisplayStyle.Flex : DisplayStyle.None;
}
private void OpenSelectedPackageDocs()
{
if (selectedPackage == null) return;
Application.OpenURL($"{selectedPackage.DocsLink}");
}
private void DemoPreview()
{
previewTitle.text = "Select a package on the left to view and import.";
previewVersion.text = "";
previewTags.text = "";
previewDescription.text = "";
previewThumbnail.style.backgroundImage = new StyleBackground();
}
private void AddPackageToList(AlgoliaPackage unityPackage)
{
PackageButton packageButton = new PackageButton();
packageButton.name = unityPackage.Title;
packageButton.PackageLabel.text = unityPackage.Title;
packageButton.clicked += () =>
{
if (selectedButton != null) selectedButton.Select(false);
selectedButton = packageButton;
selectedButton.Select(true);
SelectExample(unityPackage);
};
examplesScrollviewContainer.Add(packageButton);
}
private void SelectExample(AlgoliaPackage unityPackage)
{
if (selectedPackage == unityPackage)
{
return;
}
selectedPackage = unityPackage;
previewTitle.text = unityPackage.Title;
previewVersion.text = $"Version: {unityPackage.Version}";
previewTags.text = $"Tags: {string.Join(',', unityPackage.Tags)}";
previewDescription.text = unityPackage.Description;
string thumbnailPath = DownloadThumbnail(unityPackage.ThumbnailImage);
previewThumbnail.style.backgroundImage = new StyleBackground(LoadTextureFromDisk(thumbnailPath));
// rate-limit event tracking for previewing examples to at most 10 events per minute
DateTime currentTime = DateTime.Now;
TimeSpan elapsedTime = currentTime - lastResetTimeExamplePreviewed;
if (elapsedTime.TotalSeconds > TIME_LIMIT_EXAMPLE_PREVIEWED)
{
eventCounterExamplePreviewed = 0;
lastResetTimeExamplePreviewed = currentTime;
}
if (eventCounterExamplePreviewed < EVENT_LIMIT_EXAMPLE_PREVIEWED)
{
eventCounterExamplePreviewed++;
AnalyticsSDK.ExamplePreviewed(selectedPackage.Title, selectedPackage.Version);
}
}
#endregion
#region Search the Index for Packages
private void UpdatePackagesAsync(string query = "")
{
ShowPackagesForQuery(query);
}
public void Refresh()
{
ShowPackagesForQuery(searchField.value);
}
private void ToggleSearchedButtons(string searchKey)
{
if (string.IsNullOrEmpty(searchKey) || string.IsNullOrWhiteSpace(searchKey))
{
foreach (VisualElement child in examplesScrollviewContainer.Children())
{
child.style.display = DisplayStyle.Flex;
}
}
else
{
foreach (VisualElement child in examplesScrollviewContainer.Children())
{
bool shown = child.name.Contains(searchKey, StringComparison.InvariantCultureIgnoreCase);
child.style.display = shown ? DisplayStyle.Flex : DisplayStyle.None;
}
}
}
private void CheckSearch(ChangeEvent<string> evt)
{
ToggleSearchedButtons(evt.newValue);
if (typingCancellation != null)
{
typingCancellation.Cancel();
typingCancellation.Dispose();
}
typingCancellation = new CancellationTokenSource();
DebounceSearch(typingCancellation.Token, evt.newValue);
}
private async Task DebounceSearch(CancellationToken token, string finalText)
{
try
{
await Task.Delay(searchDelayMs, cancellationToken: token);
if (!token.IsCancellationRequested)
{
// User has finished typing
UpdatePackagesAsync(finalText);
}
}
catch (OperationCanceledException)
{
// This block is executed if the delay is cancelled
}
}
private async Task<List<AlgoliaPackage>> FetchPackagesAlgolia(string query = "")
{
SearchClient algolia = new SearchClient(AlgoliaAppKey, AlgoliaSearchKey);
SearchIndex index = algolia.InitIndex(AlgoliaIndexName);
// Construct the embedded list format needed for tag filters
var tagFilters = new List<List<string>>();
if (!ExampleCentralSettings.Data.Instance.ShowEconomyPackages)
{
tagFilters.Add(new List<string>(){"-ce"});
}
// Conduct the search, including tag filters from settings
List<AlgoliaPackage> hits = (await index.SearchAsync<ExampleCentral.Types.Algolia.UnityPackage>(
new Query(query)
{
TagFilters = tagFilters
}
)).Hits;
return hits;
}
private async Task ShowPackagesForQuery(string query = "")
{
List<AlgoliaPackage> packages = await FetchPackagesAlgolia(query);
// Clear the existing list
examplesScrollviewContainer.Clear();
foreach (AlgoliaPackage unityPackage in packages)
{
// Add each package to the list
AddPackageToList(unityPackage);
}
ToggleSearchedButtons(query);
}
#endregion
#region Downloads
private void DownloadSelectedPackage()
{
if (selectedPackage == null) return;
AnalyticsSDK.ExampleDownloaded(selectedPackage.Title, selectedPackage.Version);
DownloadUnityPackage(selectedPackage);
}
private string DownloadThumbnail(string url)
{
// get or create path to store thumbnails, use Local App Data
string thumbnailDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VRChat",
"Example Central", "Thumbnails");
Directory.CreateDirectory(thumbnailDir);
string thumbnailPath = Path.Combine(thumbnailDir, Path.GetFileName(url));
// download thumbnail if it doesn't exist
if (!File.Exists(thumbnailPath))
{
WebClient client = new System.Net.WebClient();
client.DownloadFile(url, thumbnailPath);
return thumbnailPath;
}
else
{
return thumbnailPath;
}
}
private void DownloadUnityPackage(AlgoliaPackage unityPackage)
{
string packageDownloadDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VRChat",
"Example Central", "Packages");
Directory.CreateDirectory(packageDownloadDir);
string packageDownloadPath = Path.Combine(packageDownloadDir,
$"{unityPackage.Title}-{unityPackage.Version}.unitypackage");
// download file if it doesn't exist
if (!File.Exists(packageDownloadPath))
{
WebClient client = new System.Net.WebClient();
client.DownloadFile(unityPackage.UnityPackageFile, packageDownloadPath);
}
// import the package
AssetDatabase.ImportPackage(packageDownloadPath, true);
}
private Texture2D LoadTextureFromDisk(string path)
{
Texture2D texture = new Texture2D(2, 2);
byte[] fileData = File.ReadAllBytes(path);
texture.LoadImage(fileData);
return texture;
}
#endregion
}
}