using System;
using System.IO;
using System.Reflection;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Unity.XR.CoreUtils
{
///
/// Base class for all scriptable settings that is easier to look up via-reflection.
///
///
/// DO NOT USE THIS CLASS DIRECTLY - Use the generic version, .
///
public abstract class ScriptableSettingsBase : ScriptableObject
{
const string k_AbsolutePathMessage = "Path cannot be absolute";
///
/// Message to display when path is invalid.
///
protected const string PathExceptionMessage = "Exception caught trying to create path.";
internal const string NullPathMessage = "Path cannot be null";
internal const string PathWithPeriodMessage = "Path cannot contain the character '.' before or after" +
" a directory separator";
internal const string PathWithInvalidCharacterMessage = "Paths on Windows cannot contain the following " +
"characters: ':', '*', '?', '\"', '<', '>', '|'";
static readonly char[] k_PathTrimChars =
{
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar,
' '
};
// These characters are invalid in Windows paths, and are not contained in Path.InvalidPathChars on OS X
static readonly char[] k_InvalidCharacters = { ':', '*', '?', '"', '<', '>', '|', '\\' };
static readonly string[] k_InvalidStrings = { "\\.", "/.", ".\\", "./" };
///
/// Looks up the static 'Instance' property of the given ScriptableSettings.
///
/// The type that refers to a singleton class, which implements an 'Instance' property.
/// The actual singleton instance of the specified class.
public static ScriptableSettingsBase GetInstanceByType(Type settingsType)
{
var instanceProperty = settingsType.GetProperty("Instance",
BindingFlags.Static | BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.FlattenHierarchy);
return (ScriptableSettingsBase)instanceProperty.GetValue(null, null);
}
// Awake and OnEnable can potentially have bad behavior in the editor during asset import, so we
// don't allow implementors of ScriptableSettings to use these functions at all
void Awake() { }
void OnEnable()
{
#if UNITY_EDITOR
if (Application.isPlaying)
OnLoaded();
#else
OnLoaded();
#endif
}
#if UNITY_EDITOR
internal void LoadInEditor()
{
OnLoaded();
}
#endif
///
/// Function called when all scriptable settings are loaded and ready for use.
///
protected virtual void OnLoaded()
{
}
internal static bool ValidatePath(string path, out string cleanedPath)
{
cleanedPath = path;
if (cleanedPath == null)
{
Debug.LogWarning(NullPathMessage);
return false;
}
foreach (var invalidCharacter in k_InvalidCharacters)
{
if (cleanedPath.Contains(invalidCharacter.ToString()))
{
Debug.LogWarning(PathWithInvalidCharacterMessage);
return false;
}
}
foreach (var str in k_InvalidStrings)
{
if (cleanedPath.Contains(str))
{
Debug.LogWarning(PathWithPeriodMessage);
return false;
}
}
try
{
if (Path.IsPathRooted(cleanedPath))
{
Debug.LogWarning(k_AbsolutePathMessage);
return false;
}
}
catch (Exception e)
{
Debug.LogWarning($"{PathExceptionMessage}\n{e}");
return false;
}
cleanedPath = cleanedPath.Trim(k_PathTrimChars);
var consecutiveSeparators = 0;
for (var i = cleanedPath.Length - 1; i >= 0; --i)
{
if (cleanedPath[i] == '\\' || cleanedPath[i] == '/')
{
consecutiveSeparators++;
}
else if (consecutiveSeparators > 0)
{
cleanedPath = cleanedPath.Remove(i + 1, consecutiveSeparators - 1);
consecutiveSeparators = 0;
}
}
if (cleanedPath != "")
cleanedPath = string.Concat(cleanedPath, "/");
return true;
}
}
///
/// Base class for ScriptableSettings.
///
/// The implementing type of ScriptableSettings.
public abstract class ScriptableSettingsBase : ScriptableSettingsBase where T : ScriptableObject
{
///
/// Reports whether the class inheriting from has a
/// defining a custom path for the asset.
///
protected static readonly bool HasCustomPath = typeof(T).IsDefined(typeof(ScriptableSettingsPathAttribute), true);
///
/// Singleton instance field.
///
protected static T BaseInstance;
///
/// Initialize a new ScriptableSettingsBase.
///
protected ScriptableSettingsBase()
{
if (BaseInstance != null)
{
XRLoggingUtils.LogWarning($"ScriptableSingleton {typeof(T)} already exists. This can happen if " +
$"there are two copies of the asset or if you query the singleton in a constructor.", BaseInstance);
}
}
///
/// Save this ScriptableSettings to an asset.
///
/// Format string for creating the path of the asset.
protected static void Save(string savePathFormat)
{
// We only save in the editor during edit mode
#if UNITY_EDITOR
if (Application.isPlaying || !Application.isEditor)
{
// This is expected behavior so no log necessary here
return;
}
if (BaseInstance == null)
{
XRLoggingUtils.Log("Cannot save ScriptableSettings: no instance!");
return;
}
var generatePath = true;
string savePath = null;
if (HasCustomPath)
{
var pathAttribute = typeof(T).GetAttribute(true);
if (ValidatePath(pathAttribute.Path, out var path))
{
generatePath = false;
savePath = string.Format(savePathFormat, path, GetFilePath());
try
{
CreateInstanceAsset(savePath);
}
catch (Exception e)
{
XRLoggingUtils.LogWarning($"{PathExceptionMessage}\n{e}");
generatePath = true;
}
}
if (generatePath)
XRLoggingUtils.LogWarning($"The path '{pathAttribute.Path}' is invalid. Generating a path instead.");
}
if (generatePath)
{
// We get the script path, and from there generate the save path.
// This way settings will stick with packages/repositories they were created with
var scriptData = MonoScript.FromScriptableObject(BaseInstance);
if (scriptData == null)
{
XRLoggingUtils.LogWarning($"Error saving {BaseInstance}. Could not get a MonoScript from the instance", BaseInstance);
return;
}
var assetPath = AssetDatabase.GetAssetPath(scriptData);
// Get the first folder above 'assets' or 'packages/com.package.name'
var lower = assetPath.ToLowerInvariant();
var folderEnd = 0;
const int assetsLength = 7; // "Assets/".Length
const int packagesLength = 9; // "Packages/".Length
if (lower.StartsWith("assets/"))
{
lower = lower.Substring(assetsLength);
folderEnd = lower.IndexOf('/') + assetsLength;
}
else if (lower.StartsWith("packages/"))
{
lower = assetPath.Substring(packagesLength);
folderEnd = lower.IndexOf('/') + 1;
folderEnd += lower.Substring(folderEnd).IndexOf('/') + packagesLength;
}
var specializationPath = string.Concat(assetPath.Substring(0, folderEnd), "/");
savePath = string.Format(savePathFormat, specializationPath, GetFilePath());
CreateInstanceAsset(savePath);
}
if (!Application.isBatchMode)
AssetDatabase.SaveAssets();
XRLoggingUtils.Log($"Created initial copy of settings: {GetFilePath()} at {savePath}");
#endif
}
///
/// Get the filename for this ScriptableSettings.
///
/// The filename.
protected static string GetFilePath()
{
var type = typeof(T);
return type.Name;
}
#if UNITY_EDITOR
static void CreateInstanceAsset(string savePath)
{
var folderPath = Path.GetDirectoryName(savePath);
if (folderPath == null)
throw new ArgumentException($"Path.GetDirectoryName returns null for {savePath}");
if (!Directory.Exists(folderPath))
Directory.CreateDirectory(folderPath);
var guid = AssetDatabase.AssetPathToGUID(savePath, AssetPathToGUIDOptions.OnlyExistingAssets);
if (string.IsNullOrEmpty(guid))
AssetDatabase.CreateAsset(BaseInstance, savePath);
}
#endif
}
}