using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; using UnityEngine.Events; using Debug = UnityEngine.Debug; using UnityObject = UnityEngine.Object; namespace Amanotes.Core { [CreateAssetMenu(menuName = "AmaDev/Local Build Config", fileName = "LocalBuild")] public class LocalBuild : ScriptableObject { private const string DEFAULT_BUILD_FOLDER = "../../_Build"; public KeystoreInfo keystore; [SerializeField] private string relativeBuildFolder = DEFAULT_BUILD_FOLDER; private string _buildFolder; public string buildFolder { get { if (!string.IsNullOrEmpty(_buildFolder)) return _buildFolder; _buildFolder = new DirectoryInfo(Path.Combine(Application.dataPath, relativeBuildFolder ?? DEFAULT_BUILD_FOLDER)).FullName; return _buildFolder; } } public Texture2D icon; public List listBuild = new List(); public List listUnused; public BuildRequestTS buildRQ; public UnityEvent beforeBuild; [NonSerialized] private BuildInfo _buildInfo; [NonSerialized] private int _lockCount; public int nBuildPending { get { if ((buildRQ != null) && !buildRQ.isDifferentSession) return listBuild.Count(t => t.enable); StopBuild(); return 0; } } internal static int SecondsTS => (int)(DateTime.Now - new DateTime(2010, 1, 1)).TotalSeconds; public void Lock() { _lockCount++; } public void Unlock() { _lockCount--; if ((_lockCount == 0) && (_buildInfo != null)) ExportBuild(_buildInfo); } public void StopBuild() { for (var i = 0; i < listBuild.Count; i++) listBuild[i].enable = false; } public void ProcessBuild() { BuildTarget cTarget = EditorUserBuildSettings.activeBuildTarget; for (var i = 0; i < listBuild.Count; i++) { BuildInfo item = listBuild[i]; if (!item.enable) continue; if (item.target != cTarget) continue; item.enable = false; Build(item); return; } for (var i = 0; i < listBuild.Count; i++) { BuildInfo item = listBuild[i]; if (!item.enable) continue; switch (item.target) { case BuildTarget.Android: EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Android, BuildTarget.Android); return; case BuildTarget.iOS: EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.iOS, BuildTarget.iOS); return; } } } private void RemoveUnusedAssets() { AssetDatabase.StartAssetEditing(); for (var i = 0; i < listUnused.Count; i++) { if (listUnused[i] == null) continue; string path = AssetDatabase.GetAssetPath(listUnused[i]); AssetDatabase.DeleteAsset(path); } AssetDatabase.StopAssetEditing(); } public void Build(BuildInfo info) { DryRun(info); if (_lockCount == 0) { ExportBuild(info); return; } _buildInfo = info; } public void EnableBuildForTarget(BuildTarget target, bool updateBuildNumber = true) { for (var i = 0; i < listBuild.Count; i++) { BuildInfo b = listBuild[i]; b.enable = b.target == target; if (updateBuildNumber && (b.target == target)) b.buildNumber++; } EditorUtility.SetDirty(this); } public void DryRun(BuildInfo info) { var buildDir = new DirectoryInfo(buildFolder); if (!buildDir.Exists) Directory.CreateDirectory(buildDir.FullName); RemoveUnusedAssets(); if (info.target == BuildTarget.Android) keystore.Write(); info.Write(); info.PreprocessBuild(buildFolder); if ((icon != null) && (info.buildIcon != null) && (info.buildIcon != icon)) { string p1 = AssetDatabase.GetAssetPath(info.buildIcon); string p2 = AssetDatabase.GetAssetPath(icon); byte[] bytes = File.ReadAllBytes(p1); File.WriteAllBytes(p2, bytes); AssetDatabase.ImportAsset(p2, ImportAssetOptions.ForceSynchronousImport); RepaintAll(); } _lockCount = 0; if (beforeBuild != null) beforeBuild.Invoke(); } private static void RepaintAll() { UnityObject[] windows = Resources.FindObjectsOfTypeAll(typeof(EditorWindow)); if (windows == null || windows.Length <= 0) return; for (var i = 0; i < windows.Length; i++) { var w = windows[i] as EditorWindow; if (w != null) w.Repaint(); } } private void ExportBuild(BuildInfo info) { string fullBuildPath = info.fullBuildPath(buildFolder); var buildPlayerOptions = new BuildPlayerOptions { scenes = GetScenes(), locationPathName = fullBuildPath, target = info.target, targetGroup = BuildPipeline.GetBuildTargetGroup(info.target), options = BuildOptions.None }; BuildPipeline.BuildPlayer(buildPlayerOptions); info.PostProcessBuild(buildFolder); if (nBuildPending > 0) { ProcessBuild(); return; } EditorUtility.RevealInFinder(fullBuildPath); } internal static string[] GetScenes() { return EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray(); } internal static void DrawRect(Rect rect, Color c) { Color bgColor = GUI.color; GUI.color = c; GUI.DrawTexture(rect, Texture2D.whiteTexture, ScaleMode.StretchToFill, false); GUI.color = bgColor; } [Serializable] public class BuildRequestTS { public int processId; public string machineId; public string projectPath; public bool willRunSHPostBuild = true; public bool isDifferentSession { get { if (machineId != SystemInfo.deviceUniqueIdentifier) return true; var currentProcess = Process.GetCurrentProcess(); if (processId != currentProcess.Id) return true; if (projectPath != Application.dataPath) return true; return false; } } public static BuildRequestTS Generate() { var currentProcess = Process.GetCurrentProcess(); return new BuildRequestTS { machineId = SystemInfo.deviceUniqueIdentifier, processId = currentProcess.Id, projectPath = Application.dataPath, }; } } [Serializable] public class BuildInfo { public string projectId; public string productName; public string packageName; public Texture2D buildIcon; public BuildTarget target; public string buildVersion; public int buildNumber; public bool enable; public bool isAndroid => target == BuildTarget.Android; public bool isIOS => target == BuildTarget.iOS; public string buildName => $"{projectId}_v{buildVersion}_b{buildNumber}"; public void PreprocessBuild(string buildFolder) { AssetDatabase.SaveAssets(); GC.Collect(); Resources.UnloadUnusedAssets(); EditorUtility.UnloadUnusedAssetsImmediate(); PlayerSettings.bundleVersion = buildVersion; if (isAndroid) { PlayerSettings.Android.bundleVersionCode = buildNumber; PlayerSettings.Android.buildApkPerCpuArchitecture = false; PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64 | AndroidArchitecture.ARMv7; EditorUserBuildSettings.buildAppBundle = false; EditorUserBuildSettings.exportAsGoogleAndroidProject = false; #if UNITY_6000_0_OR_NEWER AndroidBuildCompat.DisableDebugSymbols(); #elif UNITY_2021_1_OR_NEWER EditorUserBuildSettings.androidCreateSymbols = AndroidCreateSymbols.Disabled; #else EditorUserBuildSettings.androidCreateSymbolsZip = false; #endif string[] listAPKNames = GetAPKNames(buildFolder); for (var i = 0; i < listAPKNames.Length; i++) { string apkName = listAPKNames[i]; string tempName = apkName.Replace(buildName, Application.productName); if (File.Exists(tempName)) File.Delete(tempName); if (File.Exists(apkName)) File.Delete(apkName); } return; } if (isIOS) { PlayerSettings.iOS.buildNumber = buildNumber.ToString(); string path = fullBuildPath(buildFolder); if (Directory.Exists(path)) Directory.Delete(path, true); return; } Debug.LogWarning("Unsupported platform: " + target); } public string[] GetAPKNames(string buildFolder) { var buildDir = new DirectoryInfo(buildFolder); return new[] { Path.Combine(buildDir.FullName, buildName + ".apk") }; } public void PostProcessBuild(string buildFolder) { if (target != BuildTarget.Android) return; string[] listAPKNames = GetAPKNames(buildFolder); for (var i = 0; i < listAPKNames.Length; i++) { string apkName = listAPKNames[i]; string tempName = apkName.Replace(buildName, productName); if (!File.Exists(tempName)) continue; File.Move(tempName, apkName); } } public string fullBuildPath(string buildFolder) { if (buildFolder.EndsWith("/")) buildFolder = buildFolder.Substring(0, buildFolder.Length - 1); var buildDir = new DirectoryInfo(buildFolder); if (isAndroid) return Path.Combine(buildDir.FullName, buildName + ".apk"); return Path.Combine(buildDir.FullName, "XCode_" + buildName); } public BuildInfo Read() { buildVersion = PlayerSettings.bundleVersion; productName = PlayerSettings.productName; if (isAndroid) { buildNumber = PlayerSettings.Android.bundleVersionCode; #if UNITY_2023_2_OR_NEWER packageName = PlayerSettings.GetApplicationIdentifier(UnityEditor.Build.NamedBuildTarget.Android); #else packageName = PlayerSettings.GetApplicationIdentifier(BuildTargetGroup.Android); #endif } if (isIOS) { int.TryParse(PlayerSettings.iOS.buildNumber, out buildNumber); #if UNITY_2023_2_OR_NEWER packageName = PlayerSettings.GetApplicationIdentifier(UnityEditor.Build.NamedBuildTarget.iOS); #else packageName = PlayerSettings.GetApplicationIdentifier(BuildTargetGroup.iOS); #endif } return this; } public void Write() { PlayerSettings.bundleVersion = buildVersion; PlayerSettings.productName = productName; if (isAndroid) { PlayerSettings.Android.bundleVersionCode = buildNumber; #if UNITY_2023_2_OR_NEWER PlayerSettings.SetApplicationIdentifier(UnityEditor.Build.NamedBuildTarget.Android, packageName); #else PlayerSettings.SetApplicationIdentifier(BuildTargetGroup.Android, packageName); #endif } if (isIOS) { PlayerSettings.iOS.buildNumber = buildNumber.ToString(); #if UNITY_2023_2_OR_NEWER PlayerSettings.SetApplicationIdentifier(UnityEditor.Build.NamedBuildTarget.iOS, packageName); #else PlayerSettings.SetApplicationIdentifier(BuildTargetGroup.iOS, packageName); #endif } EditorApplication.RepaintProjectWindow(); } public void IncreaseVersion() { buildNumber++; if (string.IsNullOrEmpty(buildVersion)) buildVersion = "1.0.0"; List list = buildVersion.Split('.').ToList(); if (list.Count < 2) list.Add("0"); if (list.Count < 3) list.Add("0"); int last = int.Parse(list[2]); list[2] = (last + 1).ToString(); buildVersion = string.Join(".", list); } } [Serializable] public class KeystoreInfo { public string path = "AmaGDK/Store/project.keystore"; public string alias = "alias"; public string keystorePassword = "123456789"; public string aliasPassword = "123456789"; public void Write() { var keystoreFI = new FileInfo(path); PlayerSettings.Android.keystoreName = keystoreFI.FullName; #if UNITY_2023_2_OR_NEWER PlayerSettings.Android.keystorePass = keystorePassword; #else PlayerSettings.keystorePass = keystorePassword; #endif PlayerSettings.Android.keyaliasPass = aliasPassword; PlayerSettings.Android.keyaliasName = alias; } } } [InitializeOnLoad] internal class LocalBuildHelper { private static LocalBuild build; static LocalBuildHelper() { EditorApplication.update -= Update; EditorApplication.update += Update; } private static void Update() { if (EditorApplication.isCompiling || EditorApplication.isUpdating) return; if (EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode) { if (build != null) build.StopBuild(); EditorApplication.update -= Update; return; } if (build == null) { string[] guids = AssetDatabase.FindAssets("t:" + nameof(LocalBuild)); if (guids.Length == 0) { EditorApplication.update -= Update; return; } string path = AssetDatabase.GUIDToAssetPath(guids[0]); build = AssetDatabase.LoadAssetAtPath(path); if (build == null) { EditorApplication.update -= Update; return; } } EditorApplication.update -= Update; if (build.nBuildPending == 0) return; build.ProcessBuild(); } private static LocalBuild FindLocalBuildAsset(string name = null, bool allowFallback = false) { string[] arr = AssetDatabase.FindAssets("t:" + nameof(LocalBuild)); if (arr.Length == 0) { Debug.LogWarning("No LocalBuild asset found!"); return null; } for (var i = 0; i < arr.Length; i++) { string path = AssetDatabase.GUIDToAssetPath(arr[i]).Replace("\\", "/"); if (string.IsNullOrEmpty(name)) return AssetDatabase.LoadAssetAtPath(path); if (Path.GetFileName(path).ToLower().Contains(name.ToLower())) return AssetDatabase.LoadAssetAtPath(path); } if (!allowFallback) return null; return AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(arr[0]).Replace("\\", "/")); } } [CustomEditor(typeof(LocalBuild))] public class LocalBuildEditor : UnityEditor.Editor { public int bIndex; public string path; public bool drawDefault; public GUIStyle productNameStyle; public GUIStyle projectStyle; private string helpInfo; private void Init() { productNameStyle = new GUIStyle(EditorStyles.largeLabel) { alignment = TextAnchor.MiddleCenter, fontSize = 32 }; projectStyle = new GUIStyle(EditorStyles.miniLabel) { alignment = TextAnchor.MiddleCenter }; path = Application.dataPath; List arr = path.Split('/').ToList(); arr.RemoveAt(arr.Count - 1); if (arr.Count > 3) arr.RemoveRange(0, arr.Count - 3); path = string.Join("/", arr); } public override void OnInspectorGUI() { var ab = (LocalBuild)target; if (ab == null) return; if (productNameStyle == null) Init(); GUILayout.Label(PlayerSettings.productName, productNameStyle); Rect r0 = GUILayoutUtility.GetRect(0, Screen.width, 16f, 16f); r0.xMin = 0; r0.xMax += 16f; LocalBuild.DrawRect(r0, new Color32(20, 20, 20, 255)); if ((Event.current.type == EventType.MouseUp) && r0.Contains(Event.current.mousePosition)) { EditorUtility.RevealInFinder(new DirectoryInfo(ab.buildFolder).FullName); Event.current.Use(); } GUI.Label(r0, path, projectStyle); EditorGUILayout.Space(); if (string.IsNullOrEmpty(helpInfo)) helpInfo = $"\nPackageName:\t{PlayerSettings.applicationIdentifier}\nTargetPlatform:\t{EditorUserBuildSettings.activeBuildTarget}\n"; EditorGUILayout.HelpBox(helpInfo, MessageType.Info); Rect rect = GUILayoutUtility.GetLastRect(); if ((Event.current.type == EventType.MouseUp) && rect.Contains(Event.current.mousePosition)) { Event.current.Use(); helpInfo = null; Repaint(); } drawDefault = GUILayout.Toggle(drawDefault, "Draw default"); if (drawDefault) DrawDefaultInspector(); EditorGUILayout.Space(); int max = ab.listBuild.Count; if (max == 0) { EditorGUILayout.HelpBox("No BuildInfo!", MessageType.Warning); if (GUILayout.Button("Create Default")) { ab.listBuild = new List { new LocalBuild.BuildInfo { target = BuildTarget.Android }.Read(), new LocalBuild.BuildInfo { target = BuildTarget.iOS }.Read() }; } return; } serializedObject.Update(); SerializedProperty prop = serializedObject.FindProperty($"listBuild.Array.data[{bIndex}]"); prop.isExpanded = true; LocalBuild.BuildInfo info = ab.listBuild[bIndex]; EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(prop, true); if (EditorGUI.EndChangeCheck()) serializedObject.ApplyModifiedProperties(); Rect r = GUILayoutUtility.GetLastRect(); r.x = 0; r.width = Screen.width; r.height = 16f; LocalBuild.DrawRect(r, new Color32(20, 20, 20, 255)); r.xMin += 12f; GUI.Label(r, info.buildName); if ((ab.icon != null) && (ab.icon == info.buildIcon)) EditorGUILayout.HelpBox("Build Icon must be different from the template icon", MessageType.Error); GUILayout.BeginHorizontal(); if (GUILayout.Button("Read")) info.Read(); if (GUILayout.Button("Write")) info.Write(); if (GUILayout.Button("+")) info.IncreaseVersion(); GUILayout.EndHorizontal(); if (GUILayout.Button("Dry Run")) { ab.DryRun(info); helpInfo = null; Repaint(); } bIndex = EditorGUILayout.IntSlider(bIndex, 0, max - 1); var nBuilds = 0; for (var i = 0; i < ab.listBuild.Count; i++) { LocalBuild.BuildInfo item = ab.listBuild[i]; if (!item.enable) continue; nBuilds++; string hint = item.target == BuildTarget.Android ? string.Join("\n", item.GetAPKNames(ab.buildFolder)) : item.fullBuildPath(ab.buildFolder); EditorGUILayout.HelpBox(hint, MessageType.Info); } if (nBuilds == 0) return; GUILayout.BeginHorizontal(); if (GUILayout.Button("Build")) { ab.buildRQ = LocalBuild.BuildRequestTS.Generate(); ab.ProcessBuild(); GUIUtility.ExitGUI(); } if (GUILayout.Button("Stop")) ab.StopBuild(); GUILayout.EndHorizontal(); } } public class ExternalProcessUtils { public static string FileFromTemplate(string fileName, string source, params string[] tokens) { for (var i = 0; i < tokens.Length; i += 2) source = source.Replace(tokens[i], tokens[i + 1]); string filePath = Application.dataPath.Replace("/Assets", "/" + fileName); File.WriteAllText(filePath, source); return filePath; } public static void RunCMD(string cmd) { try { var proc = new Process { StartInfo = new ProcessStartInfo { FileName = "/bin/bash", Arguments = $"-c \" {cmd} \" ", CreateNoWindow = true } }; proc.Start(); proc.WaitForExit(); } catch (Exception e) { Debug.LogWarning(e); } } public static void ChmodX(string filePath) { RunCMD($"chmod +x {filePath}"); } public static void Run(string filePath, bool waitExit = false) { ChmodX(filePath); try { var process = new Process { EnableRaisingEvents = false, StartInfo = { FileName = filePath, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardInput = true, RedirectStandardError = true } }; process.Start(); if (waitExit) process.WaitForExit(); } catch (Exception e) { Debug.LogWarning(e); } } } }