ProjectCloner.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. using System.Collections;
  2. using System.Collections.Generic;
  3. using UnityEngine;
  4. using UnityEditor;
  5. using System.Linq;
  6. using System.Runtime.InteropServices;
  7. using System.IO;
  8. using UnityProjectCloner;
  9. namespace UnityProjectCloner
  10. {
  11. /// <summary>
  12. /// Contains all required methods for creating a linked clone of the Unity project.
  13. /// </summary>
  14. public class ProjectCloner
  15. {
  16. /// <summary>
  17. /// Name used for an identifying file created in the clone project directory.
  18. /// </summary>
  19. /// <remarks>
  20. /// (!) Do not change this after the clone was created, because then connection will be lost.
  21. /// </remarks>
  22. public const string CloneFileName = ".clone";
  23. /// <summary>
  24. /// Suffix added to the end of the project clone name when it is created.
  25. /// </summary>
  26. /// <remarks>
  27. /// (!) Do not change this after the clone was created, because then connection will be lost.
  28. /// </remarks>
  29. public const string CloneNameSuffix = "_clone";
  30. public const int MaxCloneProjectCount = 10;
  31. #region Managing clones
  32. /// <summary>
  33. /// Creates clone from the project currently open in Unity Editor.
  34. /// </summary>
  35. /// <returns></returns>
  36. public static Project CreateCloneFromCurrent()
  37. {
  38. if (IsClone())
  39. {
  40. Debug.LogError("This project is already a clone. Cannot clone it.");
  41. return null;
  42. }
  43. string currentProjectPath = ProjectCloner.GetCurrentProjectPath();
  44. return ProjectCloner.CreateCloneFromPath(currentProjectPath);
  45. }
  46. /// <summary>
  47. /// Creates clone of the project located at the given path.
  48. /// </summary>
  49. /// <param name="sourceProjectPath"></param>
  50. /// <returns></returns>
  51. public static Project CreateCloneFromPath(string sourceProjectPath)
  52. {
  53. Project sourceProject = new Project(sourceProjectPath);
  54. string cloneProjectPath = null;
  55. //Find available clone suffix id
  56. for (int i = 0; i < MaxCloneProjectCount; i++)
  57. {
  58. string originalProjectPath = ProjectCloner.GetCurrentProject().projectPath;
  59. string possibleCloneProjectPath = originalProjectPath + ProjectCloner.CloneNameSuffix + "_" + i;
  60. if (!Directory.Exists(possibleCloneProjectPath))
  61. {
  62. cloneProjectPath = possibleCloneProjectPath;
  63. break;
  64. }
  65. }
  66. if (string.IsNullOrEmpty(cloneProjectPath))
  67. {
  68. Debug.LogError("The number of cloned projects has reach its limit. Limit: " + MaxCloneProjectCount);
  69. return null;
  70. }
  71. Project cloneProject = new Project(cloneProjectPath);
  72. Debug.Log("Start project name: " + sourceProject);
  73. Debug.Log("Clone project name: " + cloneProject);
  74. ProjectCloner.CreateProjectFolder(cloneProject);
  75. ProjectCloner.CopyLibraryFolder(sourceProject, cloneProject);
  76. ProjectCloner.LinkFolders(sourceProject.assetPath, cloneProject.assetPath);
  77. ProjectCloner.LinkFolders(sourceProject.projectSettingsPath, cloneProject.projectSettingsPath);
  78. ProjectCloner.LinkFolders(sourceProject.packagesPath, cloneProject.packagesPath);
  79. ProjectCloner.LinkFolders(sourceProject.autoBuildPath, cloneProject.autoBuildPath);
  80. ProjectCloner.RegisterClone(cloneProject);
  81. return cloneProject;
  82. }
  83. /// <summary>
  84. /// Registers a clone by placing an identifying ".clone" file in its root directory.
  85. /// </summary>
  86. /// <param name="cloneProject"></param>
  87. private static void RegisterClone(Project cloneProject)
  88. {
  89. /// Add clone identifier file.
  90. string identifierFile = Path.Combine(cloneProject.projectPath, ProjectCloner.CloneFileName);
  91. File.Create(identifierFile).Dispose();
  92. /// Add collabignore.txt to stop the clone from messing with Unity Collaborate if it's enabled. Just in case.
  93. string collabignoreFile = Path.Combine(cloneProject.projectPath, "collabignore.txt");
  94. File.WriteAllText(collabignoreFile, "*"); /// Make it ignore ALL files in the clone.
  95. }
  96. /// <summary>
  97. /// Opens a project located at the given path (if one exists).
  98. /// </summary>
  99. /// <param name="projectPath"></param>
  100. public static void OpenProject(string projectPath)
  101. {
  102. if (!Directory.Exists(projectPath))
  103. {
  104. Debug.LogError("Cannot open the project - provided folder (" + projectPath + ") does not exist.");
  105. return;
  106. }
  107. if (projectPath == ProjectCloner.GetCurrentProjectPath())
  108. {
  109. Debug.LogError("Cannot open the project - it is already open.");
  110. return;
  111. }
  112. string fileName = EditorApplication.applicationPath;
  113. string args = "-projectPath \"" + projectPath + "\"";
  114. Debug.Log("Opening project \"" + fileName + " " + args + "\"");
  115. ProjectCloner.StartHiddenConsoleProcess(fileName, args);
  116. }
  117. /// <summary>
  118. /// Deletes the clone of the currently open project, if such exists.
  119. /// </summary>
  120. public static void DeleteClone(string cloneProjectPath)
  121. {
  122. /// Clone won't be able to delete itself.
  123. if (ProjectCloner.IsClone()) return;
  124. ///Extra precautions.
  125. if (cloneProjectPath == string.Empty) return;
  126. if (cloneProjectPath == ProjectCloner.GetOriginalProjectPath()) return;
  127. if (!cloneProjectPath.EndsWith(ProjectCloner.CloneNameSuffix)) return;
  128. //Check what OS is
  129. switch (Application.platform)
  130. {
  131. case (RuntimePlatform.WindowsEditor):
  132. Debug.Log("Attempting to delete folder \"" + cloneProjectPath + "\"");
  133. string args = "/c " + @"rmdir /s/q " + string.Format("\"{0}\"", cloneProjectPath);
  134. StartHiddenConsoleProcess("cmd.exe", args);
  135. break;
  136. case (RuntimePlatform.OSXEditor):
  137. throw new System.NotImplementedException("No Mac function implement yet :(");
  138. //break;
  139. case (RuntimePlatform.LinuxEditor):
  140. throw new System.NotImplementedException("No linux support yet :(");
  141. //break;
  142. default:
  143. Debug.LogWarning("Not in a known editor. Where are you!?");
  144. break;
  145. }
  146. }
  147. #endregion
  148. #region Creating project folders
  149. /// <summary>
  150. /// Creates an empty folder using data in the given Project object
  151. /// </summary>
  152. /// <param name="project"></param>
  153. public static void CreateProjectFolder(Project project)
  154. {
  155. string path = project.projectPath;
  156. Debug.Log("Creating new empty folder at: " + path);
  157. Directory.CreateDirectory(path);
  158. }
  159. /// <summary>
  160. /// Copies the full contents of the unity library. We want to do this to avoid the lengthy reserialization of the whole project when it opens up the clone.
  161. /// </summary>
  162. /// <param name="sourceProject"></param>
  163. /// <param name="destinationProject"></param>
  164. public static void CopyLibraryFolder(Project sourceProject, Project destinationProject)
  165. {
  166. if (Directory.Exists(destinationProject.libraryPath))
  167. {
  168. Debug.LogWarning("Library copy: destination path already exists! ");
  169. return;
  170. }
  171. Debug.Log("Library copy: " + destinationProject.libraryPath);
  172. ProjectCloner.CopyDirectoryWithProgressBar(sourceProject.libraryPath, destinationProject.libraryPath, "Cloning project '" + sourceProject.name + "'. ");
  173. }
  174. #endregion
  175. #region Creating symlinks
  176. /// <summary>
  177. /// Creates a symlink between destinationPath and sourcePath (Mac version).
  178. /// </summary>
  179. /// <param name="sourcePath"></param>
  180. /// <param name="destinationPath"></param>
  181. private static void CreateLinkMac(string sourcePath, string destinationPath)
  182. {
  183. // Debug.LogWarning("This hasn't been tested yet! I am mac-less :( Please chime in on the github if it works for you.");
  184. string cmd = "-s " + string.Format("\"{0}\" \"{1}\"", sourcePath, destinationPath);
  185. Debug.Log("Mac hard link " + cmd);
  186. // ProjectCloner.StartHiddenConsoleProcess("/bin/zsh", cmd);
  187. StartProcessWithShell("ln", cmd);
  188. }
  189. /// <summary>
  190. /// Creates a symlink between destinationPath and sourcePath (Windows version).
  191. /// </summary>
  192. /// <param name="sourcePath"></param>
  193. /// <param name="destinationPath"></param>
  194. private static void CreateLinkWin(string sourcePath, string destinationPath)
  195. {
  196. string cmd = "/C mklink /J " + string.Format("\"{0}\" \"{1}\"", destinationPath, sourcePath);
  197. Debug.Log("Windows junction: " + cmd);
  198. ProjectCloner.StartHiddenConsoleProcess("cmd.exe", cmd);
  199. }
  200. /// <summary>
  201. /// Creates a symlink between destinationPath and sourcePath (Linux version).
  202. /// </summary>
  203. /// <param name="sourcePath"></param>
  204. /// <param name="destinationPath"></param>
  205. private static void CreateLinkLunux(string sourcePath, string destinationPath)
  206. {
  207. string cmd = string.Format("-c \"ln -s {0} {1}\"", sourcePath, destinationPath);
  208. Debug.Log("Linux junction: " + cmd);
  209. ProjectCloner.StartHiddenConsoleProcess("/bin/bash", cmd);
  210. }
  211. //TODO avoid terminal calls and use proper api stuff. See below for windows!
  212. ////https://docs.microsoft.com/en-us/windows/desktop/api/ioapiset/nf-ioapiset-deviceiocontrol
  213. //[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
  214. //private static extern bool DeviceIoControl(System.IntPtr hDevice, uint dwIoControlCode,
  215. // System.IntPtr InBuffer, int nInBufferSize,
  216. // System.IntPtr OutBuffer, int nOutBufferSize,
  217. // out int pBytesReturned, System.IntPtr lpOverlapped);
  218. /// <summary>
  219. /// Create a link / junction from the real project to it's clone.
  220. /// </summary>
  221. /// <param name="sourcePath"></param>
  222. /// <param name="destinationPath"></param>
  223. public static void LinkFolders(string sourcePath, string destinationPath)
  224. {
  225. if ((Directory.Exists(destinationPath) == false) && (Directory.Exists(sourcePath) == true))
  226. {
  227. switch (Application.platform)
  228. {
  229. case (RuntimePlatform.WindowsEditor):
  230. CreateLinkWin(sourcePath, destinationPath);
  231. break;
  232. case (RuntimePlatform.OSXEditor):
  233. CreateLinkMac(sourcePath, destinationPath);
  234. break;
  235. case (RuntimePlatform.LinuxEditor):
  236. CreateLinkLunux(sourcePath, destinationPath);
  237. break;
  238. default:
  239. Debug.LogWarning("Not in a known editor. Where are you!?");
  240. break;
  241. }
  242. }
  243. else
  244. {
  245. Debug.LogWarning("Skipping Asset link, it already exists: " + destinationPath);
  246. }
  247. }
  248. #endregion
  249. #region Utility methods
  250. /// <summary>
  251. /// Returns true is the project currently open in Unity Editor is a clone.
  252. /// </summary>
  253. /// <returns></returns>
  254. public static bool IsClone()
  255. {
  256. /// The project is a clone if its root directory contains an empty file named ".clone".
  257. string cloneFilePath = Path.Combine(ProjectCloner.GetCurrentProjectPath(), ProjectCloner.CloneFileName);
  258. bool isClone = File.Exists(cloneFilePath);
  259. return isClone;
  260. }
  261. /// <summary>
  262. /// Get the path to the current unityEditor project folder's info
  263. /// </summary>
  264. /// <returns></returns>
  265. public static string GetCurrentProjectPath()
  266. {
  267. return Application.dataPath.Replace("/Assets", "");
  268. }
  269. /// <summary>
  270. /// Return a project object that describes all the paths we need to clone it.
  271. /// </summary>
  272. /// <returns></returns>
  273. public static Project GetCurrentProject()
  274. {
  275. string pathString = ProjectCloner.GetCurrentProjectPath();
  276. return new Project(pathString);
  277. }
  278. /// <summary>
  279. /// Returns the path to the original project.
  280. /// If currently open project is the original, returns its own path.
  281. /// If the original project folder cannot be found, retuns an empty string.
  282. /// </summary>
  283. /// <returns></returns>
  284. public static string GetOriginalProjectPath()
  285. {
  286. if (IsClone())
  287. {
  288. /// If this is a clone...
  289. /// Original project path can be deduced by removing the suffix from the clone's path.
  290. string cloneProjectPath = ProjectCloner.GetCurrentProject().projectPath;
  291. int index = cloneProjectPath.LastIndexOf(ProjectCloner.CloneNameSuffix);
  292. if (index > 0)
  293. {
  294. string originalProjectPath = cloneProjectPath.Substring(0, index);
  295. if (Directory.Exists(originalProjectPath)) return originalProjectPath;
  296. }
  297. return string.Empty;
  298. }
  299. else
  300. {
  301. /// If this is the original, we return its own path.
  302. return ProjectCloner.GetCurrentProjectPath();
  303. }
  304. }
  305. /// <summary>
  306. /// Returns all clone projects path.
  307. /// </summary>
  308. /// <returns></returns>
  309. public static List<string> GetCloneProjectsPath()
  310. {
  311. List<string> projectsPath = new List<string>();
  312. for (int i = 0; i < MaxCloneProjectCount; i++)
  313. {
  314. string originalProjectPath = ProjectCloner.GetCurrentProject().projectPath;
  315. string cloneProjectPath = originalProjectPath + ProjectCloner.CloneNameSuffix + "_" + i;
  316. if (Directory.Exists(cloneProjectPath))
  317. projectsPath.Add(cloneProjectPath);
  318. }
  319. return projectsPath;
  320. }
  321. /// <summary>
  322. /// Copies directory located at sourcePath to destinationPath. Displays a progress bar.
  323. /// </summary>
  324. /// <param name="source">Directory to be copied.</param>
  325. /// <param name="destination">Destination directory (created automatically if needed).</param>
  326. /// <param name="progressBarPrefix">Optional string added to the beginning of the progress bar window header.</param>
  327. public static void CopyDirectoryWithProgressBar(string sourcePath, string destinationPath, string progressBarPrefix = "")
  328. {
  329. var source = new DirectoryInfo(sourcePath);
  330. var destination = new DirectoryInfo(destinationPath);
  331. long totalBytes = 0;
  332. long copiedBytes = 0;
  333. ProjectCloner.CopyDirectoryWithProgressBarRecursive(source, destination, ref totalBytes, ref copiedBytes, progressBarPrefix);
  334. EditorUtility.ClearProgressBar();
  335. }
  336. /// <summary>
  337. /// Copies directory located at sourcePath to destinationPath. Displays a progress bar.
  338. /// Same as the previous method, but uses recursion to copy all nested folders as well.
  339. /// </summary>
  340. /// <param name="source">Directory to be copied.</param>
  341. /// <param name="destination">Destination directory (created automatically if needed).</param>
  342. /// <param name="totalBytes">Total bytes to be copied. Calculated automatically, initialize at 0.</param>
  343. /// <param name="copiedBytes">To track already copied bytes. Calculated automatically, initialize at 0.</param>
  344. /// <param name="progressBarPrefix">Optional string added to the beginning of the progress bar window header.</param>
  345. private static void CopyDirectoryWithProgressBarRecursive(DirectoryInfo source, DirectoryInfo destination, ref long totalBytes, ref long copiedBytes, string progressBarPrefix = "")
  346. {
  347. /// Directory cannot be copied into itself.
  348. if (source.FullName.ToLower() == destination.FullName.ToLower())
  349. {
  350. Debug.LogError("Cannot copy directory into itself.");
  351. return;
  352. }
  353. /// Calculate total bytes, if required.
  354. if (totalBytes == 0)
  355. {
  356. totalBytes = ProjectCloner.GetDirectorySize(source, true, progressBarPrefix);
  357. }
  358. /// Create destination directory, if required.
  359. if (!Directory.Exists(destination.FullName))
  360. {
  361. Directory.CreateDirectory(destination.FullName);
  362. }
  363. /// Copy all files from the source.
  364. foreach (FileInfo file in source.GetFiles())
  365. {
  366. try
  367. {
  368. file.CopyTo(Path.Combine(destination.ToString(), file.Name), true);
  369. }
  370. catch (IOException)
  371. {
  372. /// Some files may throw IOException if they are currently open in Unity editor.
  373. /// Just ignore them in such case.
  374. }
  375. /// Account the copied file size.
  376. copiedBytes += file.Length;
  377. /// Display the progress bar.
  378. float progress = (float)copiedBytes / (float)totalBytes;
  379. bool cancelCopy = EditorUtility.DisplayCancelableProgressBar(
  380. progressBarPrefix + "Copying '" + source.FullName + "' to '" + destination.FullName + "'...",
  381. "(" + (progress * 100f).ToString("F2") + "%) Copying file '" + file.Name + "'...",
  382. progress);
  383. if (cancelCopy) return;
  384. }
  385. /// Copy all nested directories from the source.
  386. foreach (DirectoryInfo sourceNestedDir in source.GetDirectories())
  387. {
  388. DirectoryInfo nextDestingationNestedDir = destination.CreateSubdirectory(sourceNestedDir.Name);
  389. ProjectCloner.CopyDirectoryWithProgressBarRecursive(sourceNestedDir, nextDestingationNestedDir, ref totalBytes, ref copiedBytes, progressBarPrefix);
  390. }
  391. }
  392. /// <summary>
  393. /// Calculates the size of the given directory. Displays a progress bar.
  394. /// </summary>
  395. /// <param name="directory">Directory, which size has to be calculated.</param>
  396. /// <param name="includeNested">If true, size will include all nested directories.</param>
  397. /// <param name="progressBarPrefix">Optional string added to the beginning of the progress bar window header.</param>
  398. /// <returns>Size of the directory in bytes.</returns>
  399. private static long GetDirectorySize(DirectoryInfo directory, bool includeNested = false, string progressBarPrefix = "")
  400. {
  401. EditorUtility.DisplayProgressBar(progressBarPrefix + "Calculating size of directories...", "Scanning '" + directory.FullName + "'...", 0f);
  402. /// Calculate size of all files in directory.
  403. long filesSize = directory.EnumerateFiles().Sum((FileInfo file) => file.Length);
  404. /// Calculate size of all nested directories.
  405. long directoriesSize = 0;
  406. if (includeNested)
  407. {
  408. IEnumerable<DirectoryInfo> nestedDirectories = directory.EnumerateDirectories();
  409. foreach (DirectoryInfo nestedDir in nestedDirectories)
  410. {
  411. directoriesSize += ProjectCloner.GetDirectorySize(nestedDir, true, progressBarPrefix);
  412. }
  413. }
  414. return filesSize + directoriesSize;
  415. }
  416. /// <summary>
  417. /// Starts process in the system console, taking the given fileName and args.
  418. /// </summary>
  419. /// <param name="fileName"></param>
  420. /// <param name="args"></param>
  421. private static void StartHiddenConsoleProcess(string fileName, string args)
  422. {
  423. var process = new System.Diagnostics.Process();
  424. //process.StartInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden;
  425. process.StartInfo.FileName = fileName;
  426. process.StartInfo.Arguments = args;
  427. process.Start();
  428. }
  429. /// <summary>
  430. /// starts process with the system shell
  431. /// </summary>
  432. /// <param name="cmdAndArgs"></param>
  433. /// <returns></returns>
  434. private static void StartProcessWithShell(string cmdName, string Args)
  435. {
  436. var process = new System.Diagnostics.Process();
  437. process.StartInfo.UseShellExecute = true;
  438. process.StartInfo.FileName = cmdName;
  439. // process.StartInfo.RedirectStandardError = true;
  440. process.StartInfo.Arguments = Args;
  441. process.Start();
  442. process.WaitForExit(-1);
  443. Debug.Log($"cmd process exiting with code :{process.ExitCode}");
  444. }
  445. #endregion
  446. }
  447. }