勉強不足で至らんブログ

勉強不足ですが色々と書いていきます。

UPMでsubmoduleが追加されないのでsubmodule部分を補填するEditor拡張作ってみた

Unity Advent Calendar 2020の記事です

Unity #3 Advent Calendar 2020 の2日目の記事になります。

Unity#3まであるのすごいですね

Unity Package Managerは便利

Unity Package ManagerでAssetsディレクトリ配下がスッキリするようになってかなり助かっています。そして openupm も使ったりしています。

最近 VRM を使っていて VroidStudio を使ってモデルを作ったのでVRMモデルで遊んでいます。 そこで自分のUnityProjectでVRMモデルを入れて UniVRM を使って制御をしたりしています。

UniVRMは標準のUPMで入れようとしたときにうまくいかなかったのでopenupmで導入しています。

そして、この記事はかなり力技で厳密にsubmoduleのコミット持ってくるとかせずに最新のものを適応させたりしてるのでご了承ください。

以下がリポジトリ

github.com

UniVRMをopenupmで導入したらエラーが出る

f:id:MakeTake:20201114231820p:plain

MToon というものがないそうです。これでは動かせないので解決していきます。 UnityのProejctで確認しても空のディレクトリとなっていました。

f:id:MakeTake:20201114231655p:plain

GitHubを確認するとsubmoduleで追加されたディレクトリのようです。

f:id:MakeTake:20201114232249p:plain

ということはupmはsubmoduleのディレクトリは落とさないようになっているのでしょう。自分で追加する可能性もあるのでそれはそうって感じですね。

Git URL からのインストール という公式に乗っ取りMToonを追加しようとすると

f:id:MakeTake:20201118201220p:plain

エラーがでました。upmに対応してないようです。なので今回はEditor拡張を作って解決したいと思ったので作りました。

submoduleを持ってくればええやんけ

シンプル単純愚直に考えてsubmoduleをgitで引っ張てくれればええやろと思いました。

Packageのコンテンツは Library/PackageCache/ 配下に羅列されます。

f:id:MakeTake:20201114233221p:plain

当初この各Packageの下には .git があってgitで操作できると思ってましたがありません。が、それでもいいので .gitmodule というsubmoduleを管理しているファイルを引っ張れればまだいいと思いましたが消し去られていました。( .gitattribute なども無くなっていたのでgit周り全部取り除かれているみたいです )

そこでファイルを漁っているとupm経由になったことでREADME.mdが追加されており開くと

# VRMShaders

VRM model's supported shaders in Unity.

## Import VRMShaders (Unity 2019.3.4f1~)

`Window` -> `Package Manager` -> `Add package from git URL` and paste `https://github.com/vrm-c/UniVRM.git?path=/Assets/VRMShaders`.

or add the package name and git URL in `Packages/manifest.json`:

{
  "dependencies": {
    "com.vrmc.vrmshaders": "https://github.com/vrm-c/UniVRM.git?path=/Assets/VRMShaders",
  }
}

もろjsonでurlが記載されていたのでREADME.mdから取得するようにしました。

これでGitHubで.gitmoduleを確認してsubmoduleのリポジトリをzipで落として突っ込もうと思います。

実装

実装なんて知らない!ってお方は次の 使い方 まで飛ばしてください。

PackageSubmoduleDownloader.cs(全体)

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
using Debug = UnityEngine.Debug;

namespace PackageSubmoduleDownloader
{
    public class PackageSubmoduleDownloader
    {

        private static float progress;
        private static float per = 1;
        private static readonly List<string> submoduleDirectories = new List<string>();

        private static string UnityDirectoryPath = $"{Application.dataPath}/..";
        private static string PackageCachePath => $"{UnityDirectoryPath}/Library/PackageCache";
        private static string TempDirectoryPath = $"{UnityDirectoryPath}/Temp";

        [MenuItem("Assets/Package Submodule Downloader")]
        public static async Task Execute()
        {
            AssetDatabase.DisallowAutoRefresh();
            progress = 0;
            UpdateProgressBar(0.1f, nameof(Execute));
            await DownloadSubmoduleAsync();
            AssetDatabase.Refresh();
            UpdateProgressBar(1 - progress, "Finish");
            AssetDatabase.AllowAutoRefresh();
            EditorUtility.ClearProgressBar();
        }
        
        private static void UpdateProgressBar(float addProgress, string info)
        {
            if (progress + addProgress > 0.9) addProgress = 0;
            progress += addProgress;
            Debug.Log($"[{nameof(PackageSubmoduleDownloader)}] {info} : {progress}");
            EditorUtility.DisplayProgressBar(nameof(PackageSubmoduleDownloader), info, progress / per);
        }
        
        public static async Task DownloadSubmoduleAsync()
        {
            var directories = Directory.GetDirectories(PackageCachePath);
            per = directories.Length;
            foreach (var directory in directories)
            {
                submoduleDirectories.Clear();
                SearchEmptyDirectory(directory);
                var isExitsSubModule = submoduleDirectories.Count != 0;
                if (!isExitsSubModule) continue;
                UpdateProgressBar(0.3f, nameof(GetGitHubUrl));

                var githubUrl = GetGitHubUrl(directory);
                var repositoryName = githubUrl.Split('/').Last();
                var downloadPath = $"{TempDirectoryPath}/{repositoryName}";
                var gitmoduleString = await GitSubmodulesAsync(githubUrl, downloadPath);
                if (gitmoduleString == "") continue;
                UpdateProgressBar(0.3f, nameof(ParseUrls));

                var submoduleUrls = ParseUrls(gitmoduleString);
                var per = submoduleUrls.Count();
                foreach (var url in submoduleUrls)
                {
                    var directoryName = url.Split('/').Last();
                    var tempDirectory = $"{TempDirectoryPath}/{directoryName}";
                    await DownloadRepositoryZipAsync(url, $"{TempDirectoryPath}/{directoryName}");
                    UpdateProgressBar(0.1f / per, nameof(DownloadRepositoryZipAsync));
                    foreach (var subModuleDirectory in from subModuleDirectory in submoduleDirectories let isTargetDirecotry = subModuleDirectory.Contains(directoryName) where isTargetDirecotry select subModuleDirectory)
                    {
                        await UnZipAsync($"{tempDirectory}.zip");
                        UpdateProgressBar(0.1f / per, nameof(UnZipAsync));
                        var source = Directory.GetDirectories(tempDirectory).First();
                        DirectoryMove(source, subModuleDirectory);
                    }
                    UpdateProgressBar(0.1f / per, nameof(DirectoryMove));
                }
            }
        }
        
        private static void SearchEmptyDirectory(string path)
        {
            foreach (var directory in Directory.GetDirectories(path))
            {
                SearchEmptyDirectory(directory);
                var isEmptyFiles = Directory.GetFiles(directory).Length == 0;
                var isEmptyDirectories = Directory.GetDirectories(directory).Length == 0;
                if (isEmptyFiles && isEmptyDirectories)
                {
                    submoduleDirectories.Add(directory);
                }
            }
        }
        
        private static string GetGitHubUrl(string path)
        {
            var readmeFileName = $"{path}/README.md";
            if (!File.Exists(readmeFileName)) return "";
            using (var raedmeFile = new StreamReader(readmeFileName))
            {
                var readme = raedmeFile.ReadToEnd().Split('\n');
                var urlLine = readme.FirstOrDefault(x => x.StartsWith("    \"com."));
                if (urlLine == null) return "";
                // e.g.)    "com.example.package": "https://github.com/example/package",
                return urlLine.Split('"')[3].Split('?').First().Replace(".git", "");
            }
        }

        private static async Task<string> GitSubmodulesAsync(string url, string downloadPath)
        {
            var gitmoduleUrl = $"{url}/master/.gitmodules".Replace("github.com", "raw.githubusercontent.com");
            var isExistsGitModules = await IsExistsGitSubmodulesAsync(gitmoduleUrl);
            if (!isExistsGitModules) return "";
            Directory.CreateDirectory(downloadPath);
            var client = new HttpClient();
            var response = await client.GetAsync(gitmoduleUrl);
            return await response.Content.ReadAsStringAsync();
        }

        private static async Task<bool> IsExistsGitSubmodulesAsync(string url)
        {
            var client = new HttpClient();
            var response = await client.GetAsync(url);
            try
            {
                response.EnsureSuccessStatusCode();
            }
            catch
            {
                // ignored
            }
            return response.IsSuccessStatusCode;
        }

        private static IEnumerable<string> ParseUrls(string gitmoduleString)
        {
            var fileValue = gitmoduleString.Split('\n');
            return fileValue.Where(x => x.StartsWith("  url =")).Select(x => x.Replace("  url = ", "").Replace(".git", ""));
        }

        private static async Task DownloadRepositoryZipAsync(string url, string downloadPath)
        {
            var completionSource = new TaskCompletionSource<TaskStatus>();
            using (var client = new WebClient())
            {
                var lastVersion = await GetLastTagAsync($"{url}/releases/latest/download");
                client.DownloadFileCompleted += (sender, args) =>
                {
                    completionSource.SetResult(TaskStatus.RanToCompletion);
                };
                client.DownloadFileAsync(new Uri($"{url}/archive/{lastVersion}.zip"),  $"{downloadPath}.zip");
            }
            await completionSource.Task;
        }

        private static async Task<string> GetLastTagAsync(string url)
        {
            var client = new HttpClient();
            var response = await client.GetAsync(url);
            try
            {
                response.EnsureSuccessStatusCode();
            }
            catch
            {
                // ignored
            }
            return response.RequestMessage.RequestUri.ToString().Split('/').Last();
        }
        
        private static Task<int> UnZipAsync(string zipPath)
        {
            var completionSource = new TaskCompletionSource<int>();
            var contentName = zipPath.Replace(".zip", "").Split('/').Last();
            var outputPath = $"{TempDirectoryPath}/{contentName}";
#if UNITY_EDITOR_WIN
            var unityEditorDirectory = System.AppDomain.CurrentDomain.BaseDirectory;
            var sevenZipPath = $"{unityEditorDirectory}/Data/Tools/7z.exe";
            var fileName = $"\"{sevenZipPath}\"";
            var arguments = $"x \"{zipPath}\" -o\"{outputPath}\" -r";
#else
            var fileName = "unzip";
            var arguments = $"\"{zipPath}\" -d \"{outputPath}\"";
#endif
            if (Directory.Exists(outputPath)) Directory.Delete(outputPath, true);
            var process = new Process
            {
                StartInfo =
                {
                    FileName = fileName,
                    Arguments = arguments,
                    UseShellExecute = false,
                    CreateNoWindow = true,
                    RedirectStandardOutput = true,
                    RedirectStandardError = true
                },
                EnableRaisingEvents = true
            };
            
            process.Exited += (sender, args) =>
            {
                completionSource.SetResult(process.ExitCode);
                process.Dispose();
            };
            process.OutputDataReceived += (sender, args) => {
                if (!string.IsNullOrEmpty(args.Data)) {
                    Debug.Log(args.Data);
                }
            };
            process.ErrorDataReceived += (sender, args) => {
                if (!string.IsNullOrEmpty(args.Data)) {
                    Debug.LogError(args.Data);
                }
            };
            process.Start();
            return completionSource.Task;
        }
        
        private static void DirectoryMove(string sourceDirectory, string destDirectory)
        {
            var sourceInfo = new DirectoryInfo(sourceDirectory);

            var directories = sourceInfo.GetDirectories();
            Directory.CreateDirectory(destDirectory);
            
            var files = sourceInfo.GetFiles();
            foreach (var file in files)
            {
                var destFileName = Path.Combine(destDirectory, file.Name);

                File.Move(file.FullName, destFileName);
            }

            foreach (var directory in directories)
            {
                var dest = Path.Combine(destDirectory, directory.Name);
                DirectoryMove(directory.FullName, dest);
            }
        }
    }
}

分割してコメントを入れて解説とさせてもらいます。

        // Editorから実行するメソッド
        [MenuItem("Assets/Package Submodule Downloader")]
        public static async Task Execute()
        {
            // 処理が終わるまでEditorの更新がかからないようにする
            AssetDatabase.DisallowAutoRefresh();
            progress = 0;
            UpdateProgressBar(0.1f, nameof(Execute));
            await DownloadSubmoduleAsync();
            AssetDatabase.Refresh();
            UpdateProgressBar(1 - progress, "Finish");
            // 処理が終わるのでEditorの更新をかけていいようにする
            AssetDatabase.AllowAutoRefresh();
            EditorUtility.ClearProgressBar();
        }
        
        // 適当にプログレスバーを出しておく
        private static void UpdateProgressBar(float addProgress, string info)
        {
            if (progress + addProgress > 0.9) addProgress = 0;
            progress += addProgress;
            Debug.Log($"[{nameof(PackageSubmoduleDownloader)}] {info} : {progress}");
            EditorUtility.DisplayProgressBar(nameof(PackageSubmoduleDownloader), info, progress / per);
        }
        
        public static async Task DownloadSubmoduleAsync()
        {
            // UPMの配置場所の取得
            var directories = Directory.GetDirectories(PackageCachePath);
            per = directories.Length;
            foreach (var directory in directories)
            {
                submoduleDirectories.Clear();
                // 空のディレクトリがあればsubmodule候補として列挙する
                SearchEmptyDirectory(directory);
                var isExitsSubModule = submoduleDirectories.Count != 0;
                if (!isExitsSubModule) continue;
                UpdateProgressBar(0.3f, nameof(GetGitHubUrl));

                // README.mdからGitHubのURLを取得する
                var githubUrl = GetGitHubUrl(directory);
                var repositoryName = githubUrl.Split('/').Last();
                var downloadPath = $"{TempDirectoryPath}/{repositoryName}";
                // GitHubのURLから最新の.submoduleを読み取る
                var gitmoduleString = await GitSubmodulesAsync(githubUrl, downloadPath);
                if (gitmoduleString == "") continue;
                UpdateProgressBar(0.3f, nameof(ParseUrls));
                
                // .submoduleに定義されてるurlを列挙する
                var submoduleUrls = ParseUrls(gitmoduleString);
                var per = submoduleUrls.Count();
                foreach (var url in submoduleUrls)
                {
                    var directoryName = url.Split('/').Last();
                    var tempDirectory = $"{TempDirectoryPath}/{directoryName}";
                    // submoduleの最新のzipをUnityのTempにダウンロードする
                    await DownloadRepositoryZipAsync(url, $"{TempDirectoryPath}/{directoryName}");
                    UpdateProgressBar(0.1f / per, nameof(DownloadRepositoryZipAsync));
                    // submoduleの名前と空のディレクトリの名前を突き合わせて合致していたら処理を行う
                    foreach (var subModuleDirectory in from subModuleDirectory in submoduleDirectories let isTargetDirecotry = subModuleDirectory.Contains(directoryName) where isTargetDirecotry select subModuleDirectory)
                    {
                        // ダウンロードしたzipをunzipする
                        await UnZipAsync($"{tempDirectory}.zip");
                        UpdateProgressBar(0.1f / per, nameof(UnZipAsync));
                        var source = Directory.GetDirectories(tempDirectory).First();
                        // tempディレクトリからUPMのディレクトリに移動させる
                        DirectoryMove(source, subModuleDirectory);
                    }
                    UpdateProgressBar(0.1f / per, nameof(DirectoryMove));
                }
            }
        }

以降はメモっておきたいメソッドをピックアップしていきます

        private static string GetGitHubUrl(string path)
        {
            var readmeFileName = $"{path}/README.md";
            if (!File.Exists(readmeFileName)) return "";
            using (var raedmeFile = new StreamReader(readmeFileName))
            {
                var readme = raedmeFile.ReadToEnd().Split('\n');
                // urlのある行はこういう文字列から始まるという力技検索
                var urlLine = readme.FirstOrDefault(x => x.StartsWith("    \"com."));
                if (urlLine == null) return "";
                // こういう文字列が絶対くる前提というの力技処理
                // e.g.)    "com.example.package": "https://github.com/example/package",
                return urlLine.Split('"')[3].Split('?').First().Replace(".git", "");
            }
        }

次はunzip処理

        private static Task<int> UnZipAsync(string zipPath)
        {
            var completionSource = new TaskCompletionSource<int>();
            var contentName = zipPath.Replace(".zip", "").Split('/').Last();
            var outputPath = $"{TempDirectoryPath}/{contentName}";
#if UNITY_EDITOR_WIN
            // Unity.exeの場所を取得してUnity.exe周りにある7-Zipのexeを取得してzip解凍に利用する
            var unityEditorDirectory = System.AppDomain.CurrentDomain.BaseDirectory;
            var sevenZipPath = $"{unityEditorDirectory}/Data/Tools/7z.exe";
            var fileName = $"\"{sevenZipPath}\"";
            var arguments = $"x \"{zipPath}\" -o\"{outputPath}\" -r";
#else
            // unzipはmac/linux系にはあるでしょって前提で書いたもの Unity.app配下にも7z.aとかあったけどlinuxと共通にしたかったのでスルー
            var fileName = "unzip";
            var arguments = $"\"{zipPath}\" -d \"{outputPath}\"";
#endif

Windowsで解凍処理どうしようかなと思ってましたがUnityEngineに付属するならええやろ!って感じで利用しています。 以前にもWebGLのビルドツールにPython環境が丸ごと入っていたりと利用できる環境があったりするので漁ってみるのもおすすめです。 → Unity WebGLのBuild and RunのRunを実行する拡張Pythonについては記載

Unityのバージョンアップで使えなくなる可能性はあるので永続的な保証はありません。

特筆する処理はこれくらいだと思います。

使い方

"com.mizotake.packagesubmoduledownloader": "https://github.com/MizoTake/PackageSubmoduleDownloader.git?path=Assets"

manifest.json に追記すれば追加できます。

Assets/PackageSubmoduleDownloader がメニューに追加されるので

f:id:MakeTake:20201116191340p:plain

実行

たぶん以下のようなエラーがでるので Force Quit します。一度Editorを落としましょう。

f:id:MakeTake:20201118202634p:plain

そしてEditorを開きなおしてください。

f:id:MakeTake:20201116183258p:plain

たぶん MToon が入っていると思います。もし入っていない場合はもう一度 Assets/PackageSubmoduleDownloader を実行するとエラーなく入ると思います。

まとめ

今回Editorを再起動させない方法を色々考えましたがわかりませんでした。自前で.metaのGUID追加して認識するかな?とか思いましたが「思ったより沼が深かった」案件だったので断念して再起動の手をとっています。 ちなみに ReImport All などをすると今回のEditor拡張で追加したディレクトリは消えるので再度同じ手順をとる必要がでてきます。

何かと力技ですが自分の一番の目的は MToon を自分で入れなくてよくなったので満足です。汎用的に書いてるつもりですが何かと穴はありそうです。

こんな力技なアプローチがあるんだな程度にとって頂くといいと思います。

記事を書いて数日後に思ったのですがMtoonをforkしてpackage.json追加したらupmで入れられたのでは…いやはや…いやはや…

github.com

勝手ながらforkして package.json 追加いたしました、これPR投げていいのかなぁとOSS慣れしてないのでわかないので自分はこれでMToonを使わせて頂きます🙏