405 строки
15 KiB
C#
405 строки
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Xml;
|
|
|
|
using Mono.Options;
|
|
|
|
namespace xsiminstaller {
|
|
class MainClass {
|
|
static bool print_simulators;
|
|
static int verbose;
|
|
|
|
static string TempDirectory {
|
|
get {
|
|
var rv = Path.Combine (Path.GetTempPath (), "x-provisioning");
|
|
Directory.CreateDirectory (rv);
|
|
return rv;
|
|
}
|
|
}
|
|
|
|
static bool TryExecuteAndCapture (string filename, string arguments, out string stdout)
|
|
{
|
|
var rv = TryExecuteAndCapture (filename, arguments, out stdout, out var stderr);
|
|
if (!rv)
|
|
Console.WriteLine (stderr);
|
|
return rv;
|
|
}
|
|
|
|
static bool TryExecuteAndCapture (string filename, string arguments, out string stdout, out string stderr)
|
|
{
|
|
using (var p = new Process ()) {
|
|
p.StartInfo.FileName = filename;
|
|
p.StartInfo.Arguments = arguments;
|
|
p.StartInfo.RedirectStandardOutput = true;
|
|
p.StartInfo.RedirectStandardError = true;
|
|
p.StartInfo.UseShellExecute = false;
|
|
if (verbose > 0)
|
|
Console.WriteLine ($"{filename} {arguments}");
|
|
var output = new StringBuilder ();
|
|
var error = new StringBuilder ();
|
|
var outputDone = new ManualResetEvent (false);
|
|
var errorDone = new ManualResetEvent (false);
|
|
p.OutputDataReceived += (sender, args) =>
|
|
{
|
|
if (args.Data == null) {
|
|
outputDone.Set ();
|
|
} else {
|
|
output.AppendLine (args.Data);
|
|
}
|
|
};
|
|
p.ErrorDataReceived += (sender, args) =>
|
|
{
|
|
if (args.Data == null) {
|
|
errorDone.Set ();
|
|
} else {
|
|
error.AppendLine (args.Data);
|
|
}
|
|
};
|
|
p.Start ();
|
|
p.BeginErrorReadLine ();
|
|
p.BeginOutputReadLine ();
|
|
p.WaitForExit ();
|
|
outputDone.WaitOne ();
|
|
errorDone.WaitOne ();
|
|
stdout = output.ToString ();
|
|
stderr = error.ToString ();
|
|
if (verbose > 0 && p.ExitCode != 0)
|
|
Console.WriteLine ("Failed to execute '{0} {1}'", filename, arguments);
|
|
return p.ExitCode == 0;
|
|
}
|
|
}
|
|
|
|
public static int Main (string [] args)
|
|
{
|
|
var exit_code = 0;
|
|
string xcode_app = null;
|
|
var install = new List<string> ();
|
|
var only_check = false;
|
|
var force = false;
|
|
var os = new OptionSet {
|
|
{ "xcode=", "The Xcode.app to use", (v) => xcode_app = v },
|
|
{ "install=", "ID of simulator to install. Can be repeated multiple times.", (v) => install.Add (v) },
|
|
{ "only-check", "Only check if the simulators are installed or not. Prints the name of any missing simulators, and returns 1 if any non-installed simulators were found.", (v) => only_check = true },
|
|
{ "print-simulators", "Print all detected simulators.", (v) => print_simulators = true },
|
|
{ "f|force", "Install again even if already installed.", (v) => force = true },
|
|
{ "v|verbose", "Increase verbosity", (v) => verbose++ },
|
|
{ "q|quiet", "Decrease verbosity", (v) => verbose-- },
|
|
};
|
|
|
|
var others = os.Parse (args);
|
|
if (others.Count () > 0) {
|
|
Console.WriteLine ("Unexpected arguments:");
|
|
foreach (var arg in others)
|
|
Console.WriteLine ("\t{0}", arg);
|
|
return 1;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty (xcode_app)) {
|
|
Console.WriteLine ("--xcode is required.");
|
|
return 1;
|
|
} else if (!Directory.Exists (xcode_app)) {
|
|
Console.WriteLine ("The Xcode directory {0} does not exist.", xcode_app);
|
|
return 1;
|
|
}
|
|
|
|
var plist = Path.Combine (xcode_app, "Contents", "Info.plist");
|
|
if (!File.Exists (plist)) {
|
|
Console.WriteLine ($"The Info.plist '{plist}' does not exist.");
|
|
return 1;
|
|
}
|
|
|
|
if (!TryExecuteAndCapture ("/usr/libexec/PlistBuddy", $"-c 'Print :DTXcode' '{plist}'", out var xcodeVersion))
|
|
return 1;
|
|
xcodeVersion = xcodeVersion.Trim ();
|
|
|
|
if (!TryExecuteAndCapture ("/usr/libexec/PlistBuddy", $"-c 'Print :DVTPlugInCompatibilityUUID' '{plist}'", out var xcodeUuid))
|
|
return 1;
|
|
xcodeUuid = xcodeUuid.Trim ();
|
|
|
|
xcodeVersion = xcodeVersion.Insert (xcodeVersion.Length - 2, ".");
|
|
xcodeVersion = xcodeVersion.Insert (xcodeVersion.Length - 1, ".");
|
|
var url = $"https://devimages-cdn.apple.com/downloads/xcode/simulators/index-{xcodeVersion}-{xcodeUuid}.dvtdownloadableindex";
|
|
var uri = new Uri (url);
|
|
var tmpfile = Path.Combine (TempDirectory, Path.GetFileName (uri.LocalPath));
|
|
if (!File.Exists (tmpfile)) {
|
|
var wc = new WebClient ();
|
|
try {
|
|
if (verbose > 0)
|
|
Console.WriteLine ($"Downloading '{uri}'");
|
|
wc.DownloadFile (uri, tmpfile);
|
|
} catch (Exception ex) {
|
|
// 403 means 404
|
|
if (ex is WebException we && (we.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.Forbidden) {
|
|
Console.WriteLine ($"Failed to download {url}: Not found"); // Apple's servers return a 403 if the file doesn't exist, which can be quite confusing, so show a better error.
|
|
} else {
|
|
Console.WriteLine ($"Failed to download {url}: {ex}");
|
|
}
|
|
// We couldn't download the list of simulators, but the simulator(s) we were requested to install might already be installed.
|
|
// Don't fail in that case (we'd miss any potential updates, but that's probably not too bad).
|
|
if (install.Count > 0) {
|
|
if (verbose > 0)
|
|
Console.WriteLine ("Checking if all the requested simulators are already installed");
|
|
foreach (var name in install) {
|
|
if (!IsInstalled (name, out var _)) {
|
|
Console.WriteLine (verbose > 0 ? $"The simulator '{name}' is not installed." : name);
|
|
exit_code = 1;
|
|
} else if (verbose > 0) {
|
|
Console.WriteLine ($"The simulator '{name}' is installed.");
|
|
}
|
|
}
|
|
// We can't install any missing simulators, because we don't have the download url (since we couldn't download the .dvtdownloadableindex file), so just exit.
|
|
return exit_code;
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
if (!TryExecuteAndCapture ("plutil", $"-convert xml1 -o - '{tmpfile}'", out var xml))
|
|
return 1;
|
|
|
|
var doc = new XmlDocument ();
|
|
doc.LoadXml (xml);
|
|
|
|
var downloadables = doc.SelectNodes ("//plist/dict/key[text()='downloadables']/following-sibling::array/dict");
|
|
foreach (XmlNode downloadable in downloadables) {
|
|
var nameNode = downloadable.SelectSingleNode ("key[text()='name']/following-sibling::string");
|
|
var versionNode = downloadable.SelectSingleNode ("key[text()='version']/following-sibling::string");
|
|
var sourceNode = downloadable.SelectSingleNode ("key[text()='source']/following-sibling::string");
|
|
var identifierNode = downloadable.SelectSingleNode ("key[text()='identifier']/following-sibling::string");
|
|
var fileSizeNode = downloadable.SelectSingleNode ("key[text()='fileSize']/following-sibling::integer");
|
|
var installPrefixNode = downloadable.SelectSingleNode ("key[text()='userInfo']/following-sibling::dict/key[text()='InstallPrefix']/following-sibling::string");
|
|
|
|
var version = versionNode.InnerText;
|
|
var versions = version.Split ('.');
|
|
var versionMajor = versions [0];
|
|
var versionMinor = versions [1];
|
|
var dict = new Dictionary<string, string> () {
|
|
{ "DOWNLOADABLE_VERSION_MAJOR", versionMajor },
|
|
{ "DOWNLOADABLE_VERSION_MINOR", versionMinor },
|
|
{ "DOWNLOADABLE_VERSION", version },
|
|
};
|
|
|
|
var identifier = Replace (identifierNode.InnerText, dict);
|
|
|
|
dict.Add ("DOWNLOADABLE_IDENTIFIER", identifier);
|
|
|
|
var name = Replace (nameNode.InnerText, dict);
|
|
var source = Replace (sourceNode.InnerText, dict);
|
|
var installPrefix = Replace (installPrefixNode.InnerText, dict);
|
|
var fileSize = long.Parse (fileSizeNode.InnerText);
|
|
|
|
var installed = false;
|
|
var updateAvailable = false;
|
|
|
|
if (only_check && !install.Contains (identifier))
|
|
continue;
|
|
|
|
if (IsInstalled (identifier, out var installedVersion)) {
|
|
if (installedVersion >= Version.Parse (version)) {
|
|
installed = true;
|
|
} else {
|
|
updateAvailable = true;
|
|
}
|
|
}
|
|
|
|
var doInstall = false;
|
|
if (install.Contains (identifier)) {
|
|
if (force) {
|
|
doInstall = true;
|
|
if (!only_check && verbose >= 0 && installed)
|
|
Console.WriteLine ($"The simulator '{identifier}' is already installed, but will be installed again because --force was specified.");
|
|
} else if (installed) {
|
|
if (!only_check && verbose >= 0)
|
|
Console.WriteLine ($"Not installing '{identifier}' because it's already installed and up-to-date.");
|
|
} else {
|
|
doInstall = true;
|
|
}
|
|
install.Remove (identifier);
|
|
}
|
|
|
|
if (print_simulators) {
|
|
Console.WriteLine (name);
|
|
Console.Write ($" Version: {version}");
|
|
if (updateAvailable)
|
|
Console.WriteLine ($" (an earlier version is installed: {installedVersion}");
|
|
else if (!installed)
|
|
Console.WriteLine ($" (not installed)");
|
|
else
|
|
Console.WriteLine ($" (installed)");
|
|
Console.WriteLine ($" Source: {source}");
|
|
Console.WriteLine ($" Identifier: {identifier}");
|
|
Console.WriteLine ($" InstallPrefix: {installPrefix}");
|
|
}
|
|
|
|
if (only_check) {
|
|
if (doInstall) {
|
|
if (updateAvailable) {
|
|
Console.WriteLine (verbose > 0 ? $"The simulator '{name}' is installed, but an update is available." : name);
|
|
} else {
|
|
Console.WriteLine (verbose > 0 ? $"The simulator '{name}' is not installed." : name);
|
|
}
|
|
exit_code = 1;
|
|
} else if (verbose > 0) {
|
|
Console.WriteLine ($"The simulator '{name}' is installed.");
|
|
}
|
|
}
|
|
if (doInstall && !only_check) {
|
|
Console.WriteLine ($"Installing {name}...");
|
|
if (Install (source, fileSize, installPrefix)) {
|
|
Console.WriteLine ($"Installed {name} successfully.");
|
|
} else {
|
|
Console.WriteLine ($"Failed to install {name}.");
|
|
return 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (install.Count > 0) {
|
|
Console.WriteLine ("Unknown simulators: {0}", string.Join (", ", install));
|
|
return 1;
|
|
}
|
|
|
|
return exit_code;
|
|
}
|
|
|
|
static bool IsInstalled (string identifier, out Version installedVersion)
|
|
{
|
|
if (TryExecuteAndCapture ($"pkgutil", $"--pkg-info {identifier}", out var pkgInfo, out _)) {
|
|
var lines = pkgInfo.Split ('\n');
|
|
var version = lines.First ((v) => v.StartsWith ("version: ", StringComparison.Ordinal)).Substring ("version: ".Length);
|
|
installedVersion = Version.Parse (version);
|
|
return true;
|
|
}
|
|
|
|
installedVersion = null;
|
|
return false;
|
|
}
|
|
|
|
static bool Install (string source, long fileSize, string installPrefix)
|
|
{
|
|
var download_dir = TempDirectory;
|
|
var filename = Path.GetFileName (source);
|
|
var download_path = Path.Combine (download_dir, filename);
|
|
var download = true;
|
|
|
|
if (!File.Exists (download_path)) {
|
|
Console.WriteLine ($"Downloading '{source}' to '{download_path}' (size: {fileSize} bytes = {fileSize / 1024.0 / 1024.0:N2} MB)...");
|
|
} else if (new FileInfo (download_path).Length != fileSize) {
|
|
Console.WriteLine ($"Downloading '{source}' to '{download_path}' because the existing file's size {new FileInfo (download_path).Length} does not match the expected size {fileSize}...");
|
|
} else {
|
|
download = false;
|
|
}
|
|
if (download) {
|
|
var downloadDone = new ManualResetEvent (false);
|
|
var wc = new WebClient ();
|
|
long lastProgress = 0;
|
|
var watch = Stopwatch.StartNew ();
|
|
wc.DownloadProgressChanged += (sender, progress_args) => {
|
|
var progress = progress_args.BytesReceived * 100 / fileSize;
|
|
if (progress > lastProgress) {
|
|
lastProgress = progress;
|
|
var duration = watch.Elapsed.TotalSeconds;
|
|
var speed = progress_args.BytesReceived / duration;
|
|
var timeLeft = TimeSpan.FromSeconds ((progress_args.TotalBytesToReceive - progress_args.BytesReceived) / speed);
|
|
Console.WriteLine ($"Downloaded {progress_args.BytesReceived:N0}/{fileSize:N0} bytes = {progress}% in {duration:N1}s ({speed / 1024.0 / 1024.0:N1} MB/s; approximately {timeLeft} left)");
|
|
}
|
|
};
|
|
wc.DownloadFileCompleted += (sender, download_args) => {
|
|
Console.WriteLine ($"Download completed in {watch.Elapsed.TotalSeconds}s");
|
|
if (download_args.Error != null) {
|
|
Console.WriteLine ($" with error: {download_args.Error}");
|
|
}
|
|
downloadDone.Set ();
|
|
};
|
|
wc.DownloadFileAsync (new Uri (source), download_path);
|
|
downloadDone.WaitOne ();
|
|
}
|
|
var mount_point = Path.Combine (download_dir, filename + "-mount");
|
|
Directory.CreateDirectory (mount_point);
|
|
try {
|
|
Console.WriteLine ($"Mounting '{download_path}' into '{mount_point}'...");
|
|
if (!TryExecuteAndCapture ("hdiutil", $"attach '{download_path}' -mountpoint '{mount_point}' -quiet -nobrowse", out _)) {
|
|
Console.WriteLine ("Mount failure!");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
var packages = Directory.GetFiles (mount_point, "*.pkg");
|
|
if (packages.Length == 0) {
|
|
Console.WriteLine ("Found no *.pkg files in the dmg.");
|
|
return false;
|
|
} else if (packages.Length > 1) {
|
|
Console.WriteLine ("Found more than one *.pkg file in the dmg:\n\t{0}", string.Join ("\n\t", packages));
|
|
return false;
|
|
}
|
|
|
|
// According to the package manifest, the package's install location is /.
|
|
// That's obviously not where it's installed, but I have no idea how Apple does it
|
|
// So instead decompress the package, modify the package manifest, re-create the package, and then install it.
|
|
var expanded_path = Path.Combine (download_dir + "-expanded-pkg");
|
|
if (Directory.Exists (expanded_path))
|
|
Directory.Delete (expanded_path, true);
|
|
Console.WriteLine ($"Expanding '{packages [0]}' into '{expanded_path}'...");
|
|
if (!TryExecuteAndCapture ("pkgutil", $"--expand '{packages [0]}' '{expanded_path}'", out _)) {
|
|
Console.WriteLine ($"Failed to expand {packages [0]}");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
var packageInfoPath = Path.Combine (expanded_path, "PackageInfo");
|
|
var packageInfoDoc = new XmlDocument ();
|
|
packageInfoDoc.Load (packageInfoPath);
|
|
// Add the install-location attribute to the pkg-info node
|
|
var attr = packageInfoDoc.CreateAttribute ("install-location");
|
|
attr.Value = installPrefix;
|
|
packageInfoDoc.SelectSingleNode ("/pkg-info").Attributes.Append (attr);
|
|
packageInfoDoc.Save (packageInfoPath);
|
|
|
|
var fixed_path = Path.Combine (Path.GetDirectoryName (download_path), Path.GetFileNameWithoutExtension (download_path) + "-fixed.pkg");
|
|
if (File.Exists (fixed_path))
|
|
File.Delete (fixed_path);
|
|
try {
|
|
Console.WriteLine ($"Creating fixed package '{fixed_path}' from '{expanded_path}'...");
|
|
if (!TryExecuteAndCapture ("pkgutil", $"--flatten '{expanded_path}' '{fixed_path}'", out _)) {
|
|
Console.WriteLine ("Failed to create fixed package.");
|
|
return false;
|
|
}
|
|
|
|
Console.WriteLine ($"Installing '{fixed_path}'...");
|
|
if (!TryExecuteAndCapture ("sudo", $"installer -pkg '{fixed_path}' -target / -verbose -dumplog", out _)) {
|
|
Console.WriteLine ("Failed to install package.");
|
|
return false;
|
|
}
|
|
} finally {
|
|
if (File.Exists (fixed_path))
|
|
File.Delete (fixed_path);
|
|
}
|
|
} finally {
|
|
Directory.Delete (expanded_path, true);
|
|
}
|
|
} finally {
|
|
TryExecuteAndCapture ("hdiutil", $"detach '{mount_point}' -quiet", out _);
|
|
}
|
|
} finally {
|
|
Directory.Delete (mount_point, true);
|
|
}
|
|
|
|
File.Delete (download_path);
|
|
|
|
return true;
|
|
}
|
|
|
|
static string Replace (string value, Dictionary<string, string> replacements)
|
|
{
|
|
foreach (var kvp in replacements)
|
|
value = value.Replace ($"$({kvp.Key})", kvp.Value);
|
|
return value;
|
|
}
|
|
}
|
|
}
|