diff --git a/.gitignore b/.gitignore index aa8061f0c9..7097de6024 100644 --- a/.gitignore +++ b/.gitignore @@ -255,4 +255,5 @@ paket-files/ # Python Tools for Visual Studio (PTVS) __pycache__/ -*.pyc \ No newline at end of file +*.pyc +Staging/ diff --git a/osu.Desktop.Deploy/App.config b/osu.Desktop.Deploy/App.config new file mode 100644 index 0000000000..6272e396fb --- /dev/null +++ b/osu.Desktop.Deploy/App.config @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/osu.Desktop.Deploy/GitHubObject.cs b/osu.Desktop.Deploy/GitHubObject.cs new file mode 100644 index 0000000000..f87de5cbdd --- /dev/null +++ b/osu.Desktop.Deploy/GitHubObject.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace osu.Desktop.Deploy +{ + internal class GitHubObject + { + [JsonProperty(@"id")] + public int Id; + + [JsonProperty(@"name")] + public string Name; + } +} \ No newline at end of file diff --git a/osu.Desktop.Deploy/GitHubRelease.cs b/osu.Desktop.Deploy/GitHubRelease.cs new file mode 100644 index 0000000000..7e7b04fe58 --- /dev/null +++ b/osu.Desktop.Deploy/GitHubRelease.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; + +namespace osu.Desktop.Deploy +{ + internal class GitHubRelease + { + [JsonProperty(@"id")] + public int Id; + + [JsonProperty(@"tag_name")] + public string TagName => $"v{Name}"; + + [JsonProperty(@"name")] + public string Name; + + [JsonProperty(@"draft")] + public bool Draft; + + [JsonProperty(@"prerelease")] + public bool PreRelease; + + [JsonProperty(@"upload_url")] + public string UploadUrl; + } +} \ No newline at end of file diff --git a/osu.Desktop.Deploy/Program.cs b/osu.Desktop.Deploy/Program.cs new file mode 100644 index 0000000000..07374e7541 --- /dev/null +++ b/osu.Desktop.Deploy/Program.cs @@ -0,0 +1,417 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.GitHubusercontent.com/ppy/osu-framework/master/LICENCE + +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using Newtonsoft.Json; +using osu.Framework.IO.Network; +using FileWebRequest = osu.Framework.IO.Network.FileWebRequest; +using WebRequest = osu.Framework.IO.Network.WebRequest; + +namespace osu.Desktop.Deploy +{ + internal static class Program + { + private const string nuget_path = @"packages\NuGet.CommandLine.3.5.0\tools\NuGet.exe"; + private const string squirrel_path = @"packages\squirrel.windows.1.5.2\tools\Squirrel.exe"; + private const string msbuild_path = @"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe"; + + public static string StagingFolder = ConfigurationManager.AppSettings["StagingFolder"]; + public static string ReleasesFolder = ConfigurationManager.AppSettings["ReleasesFolder"]; + public static string GitHubAccessToken = ConfigurationManager.AppSettings["GitHubAccessToken"]; + public static string GitHubUsername = ConfigurationManager.AppSettings["GitHubUsername"]; + public static string GitHubRepoName = ConfigurationManager.AppSettings["GitHubRepoName"]; + public static string SolutionName = ConfigurationManager.AppSettings["SolutionName"]; + public static string ProjectName = ConfigurationManager.AppSettings["ProjectName"]; + public static string NuSpecName = ConfigurationManager.AppSettings["NuSpecName"]; + public static string TargetName = ConfigurationManager.AppSettings["TargetName"]; + public static string PackageName = ConfigurationManager.AppSettings["PackageName"]; + public static string IconName = ConfigurationManager.AppSettings["IconName"]; + public static string CodeSigningCertificate = ConfigurationManager.AppSettings["CodeSigningCertificate"]; + + public static string GitHubApiEndpoint => $"https://api.github.com/repos/{GitHubUsername}/{GitHubRepoName}/releases"; + public static string GitHubReleasePage => $"https://github.com/{GitHubUsername}/{GitHubRepoName}/releases"; + + /// + /// How many previous build deltas we want to keep when publishing. + /// + const int keep_delta_count = 3; + + private static string codeSigningCmd => string.IsNullOrEmpty(codeSigningPassword) ? "" : $"-n \"/a /f {codeSigningCertPath} /p {codeSigningPassword} /t http://timestamp.comodoca.com/authenticode\""; + + private static string homeDir => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + private static string codeSigningCertPath => Path.Combine(homeDir, CodeSigningCertificate); + private static string solutionPath => Environment.CurrentDirectory; + private static string stagingPath => Path.Combine(solutionPath, StagingFolder); + private static string iconPath => Path.Combine(solutionPath, ProjectName, IconName); + + private static string nupkgFilename(string ver) => $"{PackageName}.{ver}.nupkg"; + private static string nupkgDistroFilename(string ver) => $"{PackageName}-{ver}-full.nupkg"; + + private static Stopwatch sw = new Stopwatch(); + + private static string codeSigningPassword; + + public static void Main(string[] args) + { + displayHeader(); + + findSolutionPath(); + + if (!Directory.Exists(ReleasesFolder)) + { + write("WARNING: No release directory found. Make sure you want this!", ConsoleColor.Yellow); + Directory.CreateDirectory(ReleasesFolder); + } + + checkGitHubReleases(); + + refreshDirectory(StagingFolder); + + //increment build number until we have a unique one. + string verBase = DateTime.Now.ToString("yyyy.Md."); + int increment = 0; + while (Directory.GetFiles(ReleasesFolder, $"*{verBase}{increment}*").Any()) + increment++; + + string version = $"{verBase}{increment}"; + + Console.ForegroundColor = ConsoleColor.White; + Console.Write($"Ready to deploy {version}: "); + Console.ReadLine(); + + sw.Start(); + + if (!string.IsNullOrEmpty(CodeSigningCertificate)) + { + Console.Write("Enter code signing password: "); + codeSigningPassword = readLineMasked(); + } + + write("Restoring NuGet packages..."); + runCommand(nuget_path, "restore " + solutionPath); + + write("Updating AssemblyInfo..."); + updateAssemblyInfo(version); + + write("Running build process..."); + runCommand(msbuild_path, $"/v:quiet /m /t:{TargetName.Replace('.', '_')} /p:OutputPath={stagingPath};Configuration=Release {SolutionName}.sln"); + + write("Creating NuGet deployment package..."); + runCommand(nuget_path, $"pack {NuSpecName} -Version {version} -Properties Configuration=Deploy -OutputDirectory {stagingPath} -BasePath {stagingPath}"); + + //prune once before checking for files so we can avoid erroring on files which aren't even needed for this build. + pruneReleases(); + + checkReleaseFiles(); + + write("Running squirrel build..."); + runCommand(squirrel_path, $"--releasify {stagingPath}\\{nupkgFilename(version)} --setupIcon {iconPath} --icon {iconPath} {codeSigningCmd} --no-msi"); + + //prune again to clean up before upload. + pruneReleases(); + + //rename setup to install. + File.Copy(Path.Combine(ReleasesFolder, "Setup.exe"), Path.Combine(ReleasesFolder, "install.exe"), true); + File.Delete(Path.Combine(ReleasesFolder, "Setup.exe")); + + uploadBuild(version); + + write("Done!", ConsoleColor.White); + Console.ReadLine(); + } + + private static void displayHeader() + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(); + Console.WriteLine(" Please note that OSU! and PPY are registered trademarks and as such covered by trademark law."); + Console.WriteLine(" Do not distribute builds of this project publicly that make use of these."); + Console.ResetColor(); + Console.WriteLine(); + } + + /// + /// Ensure we have all the files in the release directory which are expected to be there. + /// This should have been accounted for in earlier steps, and just serves as a verification step. + /// + private static void checkReleaseFiles() + { + var releaseLines = getReleaseLines(); + + //ensure we have all files necessary + foreach (var l in releaseLines) + if (!File.Exists(Path.Combine(ReleasesFolder, l.Filename))) + error($"Local file missing {l.Filename}"); + } + + private static IEnumerable getReleaseLines() => File.ReadAllLines(Path.Combine(ReleasesFolder, "RELEASES")).Select(l => new ReleaseLine(l)); + + private static void pruneReleases() + { + write("Pruning RELEASES..."); + + var releaseLines = getReleaseLines().ToList(); + + var fulls = releaseLines.Where(l => l.Filename.Contains("-full")).Reverse().Skip(1); + + //remove any FULL releases (except most recent) + foreach (var l in fulls) + { + write($"- Removing old release {l.Filename}", ConsoleColor.Yellow); + File.Delete(Path.Combine(ReleasesFolder, l.Filename)); + releaseLines.Remove(l); + } + + //remove excess deltas + var deltas = releaseLines.Where(l => l.Filename.Contains("-delta")); + if (deltas.Count() > keep_delta_count) + { + foreach (var l in deltas.Take(deltas.Count() - keep_delta_count)) + { + write($"- Removing old delta {l.Filename}", ConsoleColor.Yellow); + File.Delete(Path.Combine(ReleasesFolder, l.Filename)); + releaseLines.Remove(l); + } + } + + var lines = new List(); + releaseLines.ForEach(l => lines.Add(l.ToString())); + File.WriteAllLines(Path.Combine(ReleasesFolder, "RELEASES"), lines); + } + + private static void uploadBuild(string version) + { + if (string.IsNullOrEmpty(GitHubAccessToken) || string.IsNullOrEmpty(codeSigningCertPath)) + return; + + write("Publishing to GitHub..."); + + write($"- Creating release {version}...", ConsoleColor.Yellow); + var req = new JsonWebRequest($"{GitHubApiEndpoint}") + { + Method = HttpMethod.POST + }; + req.AddRaw(JsonConvert.SerializeObject(new GitHubRelease + { + Name = version, + Draft = true, + PreRelease = true + })); + req.AuthenticatedBlockingPerform(); + + var assetUploadUrl = req.ResponseObject.UploadUrl.Replace("{?name,label}", "?name={0}"); + foreach (var a in Directory.GetFiles(ReleasesFolder).Reverse()) //reverse to upload RELEASES first. + { + write($"- Adding asset {a}...", ConsoleColor.Yellow); + var upload = new WebRequest(assetUploadUrl, Path.GetFileName(a)) + { + Method = HttpMethod.POST, + ContentType = "application/octet-stream", + }; + + upload.AddRaw(File.ReadAllBytes(a)); + upload.AuthenticatedBlockingPerform(); + } + + openGitHubReleasePage(); + } + + private static void openGitHubReleasePage() => Process.Start(GitHubReleasePage); + + private static void checkGitHubReleases() + { + write("Checking GitHub releases..."); + var req = new JsonWebRequest>($"{GitHubApiEndpoint}"); + req.AuthenticatedBlockingPerform(); + + var lastRelease = req.ResponseObject.FirstOrDefault(); + + if (lastRelease == null) + return; + + if (lastRelease.Draft) + { + openGitHubReleasePage(); + error("There's a pending draft release! You probably don't want to push a build with this present."); + } + + //there's a previous release for this project. + var assetReq = new JsonWebRequest>($"{GitHubApiEndpoint}/{lastRelease.Id}/assets"); + assetReq.AuthenticatedBlockingPerform(); + var assets = assetReq.ResponseObject; + + //make sure our RELEASES file is the same as the last build on the server. + var releaseAsset = assets.FirstOrDefault(a => a.Name == "RELEASES"); + + //if we don't have a RELEASES asset then the previous release likely wasn't a Squirrel one. + if (releaseAsset == null) return; + + write($"Last GitHub release was {lastRelease.Name}."); + + bool requireDownload = false; + + if (!File.Exists(Path.Combine(ReleasesFolder, nupkgDistroFilename(lastRelease.Name)))) + { + write("Last verion's package not found locally.", ConsoleColor.Red); + requireDownload = true; + } + else + { + var lastReleases = new RawFileWebRequest($"{GitHubApiEndpoint}/assets/{releaseAsset.Id}"); + lastReleases.AuthenticatedBlockingPerform(); + if (File.ReadAllText(Path.Combine(ReleasesFolder, "RELEASES")) != lastReleases.ResponseString) + { + write("Server's RELEASES differed from ours.", ConsoleColor.Red); + requireDownload = true; + } + } + + if (!requireDownload) return; + + write("Refreshing local releases directory..."); + refreshDirectory(ReleasesFolder); + + foreach (var a in assets) + { + write($"- Downloading {a.Name}...", ConsoleColor.Yellow); + new FileWebRequest(Path.Combine(ReleasesFolder, a.Name), $"{GitHubApiEndpoint}/assets/{a.Id}").AuthenticatedBlockingPerform(); + } + } + + private static void refreshDirectory(string directory) + { + if (Directory.Exists(directory)) + Directory.Delete(directory, true); + Directory.CreateDirectory(directory); + } + + private static void updateAssemblyInfo(string version) + { + string file = Path.Combine(ProjectName, "Properties", "AssemblyInfo.cs"); + + var l1 = File.ReadAllLines(file); + List l2 = new List(); + foreach (var l in l1) + { + if (l.StartsWith("[assembly: AssemblyVersion(")) + l2.Add($"[assembly: AssemblyVersion(\"{version}\")]"); + else if (l.StartsWith("[assembly: AssemblyFileVersion(")) + l2.Add($"[assembly: AssemblyFileVersion(\"{version}\")]"); + else + l2.Add(l); + } + + File.WriteAllLines(file, l2); + } + + /// + /// Find the base path of the active solution (git checkout location) + /// + private static void findSolutionPath() + { + string path = Path.GetDirectoryName(Environment.CommandLine.Replace("\"", "").Trim()); + + if (string.IsNullOrEmpty(path)) + path = Environment.CurrentDirectory; + + while (!File.Exists(Path.Combine(path, $"{SolutionName}.sln"))) + path = path.Remove(path.LastIndexOf('\\')); + path += "\\"; + + Environment.CurrentDirectory = path; + } + + private static bool runCommand(string command, string args) + { + var psi = new ProcessStartInfo(command, args) + { + WorkingDirectory = solutionPath, + CreateNoWindow = true, + RedirectStandardOutput = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + }; + + Process p = Process.Start(psi); + string output = p.StandardOutput.ReadToEnd(); + if (p.ExitCode == 0) return true; + + write(output); + error($"Command {command} {args} failed!"); + return false; + } + + private static string readLineMasked() + { + var fg = Console.ForegroundColor; + Console.ForegroundColor = Console.BackgroundColor; + var ret = Console.ReadLine(); + Console.ForegroundColor = fg; + + return ret; + } + + private static void error(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"FATAL ERROR: {message}"); + + Console.ReadLine(); + Environment.Exit(-1); + } + + private static void write(string message, ConsoleColor col = ConsoleColor.Gray) + { + if (sw.ElapsedMilliseconds > 0) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write(sw.ElapsedMilliseconds.ToString().PadRight(8)); + } + Console.ForegroundColor = col; + Console.WriteLine(message); + } + + public static void AuthenticatedBlockingPerform(this WebRequest r) + { + r.AddHeader("Authorization", $"token {GitHubAccessToken}"); + r.BlockingPerform(); + } + } + + internal class RawFileWebRequest : WebRequest + { + public RawFileWebRequest(string url) : base(url) + { + } + + protected override HttpWebRequest CreateWebRequest(string requestString = null) + { + var req = base.CreateWebRequest(requestString); + req.Accept = "application/octet-stream"; + return req; + } + } + + internal class ReleaseLine + { + public string Hash; + public string Filename; + public int Filesize; + + public ReleaseLine(string line) + { + var split = line.Split(' '); + Hash = split[0]; + Filename = split[1]; + Filesize = int.Parse(split[2]); + } + + public override string ToString() => $"{Hash} {Filename} {Filesize}"; + } +} diff --git a/osu.Desktop.Deploy/Properties/AssemblyInfo.cs b/osu.Desktop.Deploy/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..c71d82df00 --- /dev/null +++ b/osu.Desktop.Deploy/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("osu.Desktop.Deploy")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("osu.Desktop.Deploy")] +[assembly: AssemblyCopyright("Copyright © 2017")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("baea2f74-0315-4667-84e0-acac0b4bf785")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/osu.Desktop.Deploy/osu.Desktop.Deploy.csproj b/osu.Desktop.Deploy/osu.Desktop.Deploy.csproj new file mode 100644 index 0000000000..122c2ec0d6 --- /dev/null +++ b/osu.Desktop.Deploy/osu.Desktop.Deploy.csproj @@ -0,0 +1,125 @@ + + + + + Debug + AnyCPU + {BAEA2F74-0315-4667-84E0-ACAC0B4BF785} + Exe + Properties + osu.Desktop.Deploy + osu.Desktop.Deploy + v4.5.2 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + osu.Desktop.Deploy.Program + + + + ..\packages\DeltaCompressionDotNet.1.0.0\lib\net45\DeltaCompressionDotNet.dll + True + + + ..\packages\DeltaCompressionDotNet.1.0.0\lib\net45\DeltaCompressionDotNet.MsDelta.dll + True + + + ..\packages\DeltaCompressionDotNet.1.0.0\lib\net45\DeltaCompressionDotNet.PatchApi.dll + True + + + ..\packages\squirrel.windows.1.5.2\lib\Net45\ICSharpCode.SharpZipLib.dll + True + + + ..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.dll + True + + + ..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.Mdb.dll + True + + + ..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.Pdb.dll + True + + + ..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.Rocks.dll + True + + + ..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll + True + + + ..\packages\squirrel.windows.1.5.2\lib\Net45\NuGet.Squirrel.dll + True + + + ..\packages\Splat.1.6.2\lib\Net45\Splat.dll + True + + + ..\packages\squirrel.windows.1.5.2\lib\Net45\Squirrel.dll + True + + + + + + + + + + + + + + + + + + + + + + + + {65dc628f-a640-4111-ab35-3a5652bc1e17} + osu.Framework.Desktop + + + {C76BF5B3-985E-4D39-95FE-97C9C879B83A} + osu.Framework + + + + + \ No newline at end of file diff --git a/osu.Desktop.Deploy/packages.config b/osu.Desktop.Deploy/packages.config new file mode 100644 index 0000000000..b7c4b9c3bb --- /dev/null +++ b/osu.Desktop.Deploy/packages.config @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/osu.Desktop/Properties/AssemblyInfo.cs b/osu.Desktop/Properties/AssemblyInfo.cs index 8784aa1ee0..c078b4caf5 100644 --- a/osu.Desktop/Properties/AssemblyInfo.cs +++ b/osu.Desktop/Properties/AssemblyInfo.cs @@ -25,5 +25,5 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("55e28cb2-7b6c-4595-8dcc-9871d8aad7e9")] -[assembly: AssemblyVersion("0.0.5")] -[assembly: AssemblyFileVersion("0.0.5")] +[assembly: AssemblyVersion("2017.212.0")] +[assembly: AssemblyFileVersion("2017.212.0")] diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec new file mode 100644 index 0000000000..2888f7c040 --- /dev/null +++ b/osu.Desktop/osu.nuspec @@ -0,0 +1,25 @@ + + + + osulazer + 0.0.0 + osulazer + ppy Pty Ltd + Dean Herbert + https://osu.ppy.sh/ + https://puu.sh/tYyXZ/9a01a5d1b0.ico + false + click the circles. to the beat. + click the circles. + testing + Copyright ppy Pty Ltd 2007-2017 + en-AU + + + + + + + + + diff --git a/osu.sln b/osu.sln index 588cabf6b6..bda60c6318 100644 --- a/osu.sln +++ b/osu.sln @@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Modes.Mania", "osu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Desktop.Tests", "osu.Desktop.Tests\osu.Desktop.Tests.csproj", "{230AC4F3-7783-49FB-9AEC-B83CDA3B9F3D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Desktop.Deploy", "osu.Desktop.Deploy\osu.Desktop.Deploy.csproj", "{BAEA2F74-0315-4667-84E0-ACAC0B4BF785}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{6EAD7610-89D8-48A2-8BE0-E348297E4D8B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -85,6 +89,8 @@ Global {230AC4F3-7783-49FB-9AEC-B83CDA3B9F3D}.Debug|Any CPU.Build.0 = Debug|Any CPU {230AC4F3-7783-49FB-9AEC-B83CDA3B9F3D}.Release|Any CPU.ActiveCfg = Release|Any CPU {230AC4F3-7783-49FB-9AEC-B83CDA3B9F3D}.Release|Any CPU.Build.0 = Release|Any CPU + {BAEA2F74-0315-4667-84E0-ACAC0B4BF785}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAEA2F74-0315-4667-84E0-ACAC0B4BF785}.Release|Any CPU.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -102,6 +108,7 @@ Global {F167E17A-7DE6-4AF5-B920-A5112296C695} = {0D37A2AD-80A4-464F-A1DE-1560B70F1CE3} {48F4582B-7687-4621-9CBE-5C24197CB536} = {0D37A2AD-80A4-464F-A1DE-1560B70F1CE3} {230AC4F3-7783-49FB-9AEC-B83CDA3B9F3D} = {0D37A2AD-80A4-464F-A1DE-1560B70F1CE3} + {BAEA2F74-0315-4667-84E0-ACAC0B4BF785} = {6EAD7610-89D8-48A2-8BE0-E348297E4D8B} EndGlobalSection GlobalSection(MonoDevelopProperties) = preSolution Policies = $0