using System; using System.Collections.Generic; using System.Linq; using System.IO; using System.Text; using System.Xml; using Xamarin.Tests; using Xamarin.Utils; using NUnit.Framework; namespace Xamarin { public enum MTouchAction { None, BuildDev, BuildSim, LaunchSim, } public enum MTouchSymbolMode { Unspecified, Default, Linker, Code, Ignore, } public enum MTouchBitcode { Unspecified, ASMOnly, Full, // LLVMOnly Marker, } class MTouchTool : BundlerTool, IDisposable { #pragma warning disable 649 // These map directly to mtouch options public MTouchAction? Action; // --sim, --dev, --launchsim, etc public bool? NoSign; public bool? FastDev; public bool? Dlsym; public string Executable; public string AppPath; public string Device; // --device public MTouchSymbolMode SymbolMode; public bool? NoFastSim; public List AppExtensions = new List (); public List Frameworks = new List (); public bool? PackageMdb; public bool? MSym; public bool? DSym; public bool? NoStrip; public string NoSymbolStrip; public string Mono; // These are a bit smarter public List AssemblyBuildTargets = new List (); static XmlDocument device_list_cache; public string LLVMOptimizations; public MTouchBitcode Bitcode; public string AotArguments; public string AotOtherArguments; public string SymbolList; #pragma warning restore 649 public MTouchTool () { Profile = Profile.iOS; } public class DeviceInfo { public string UDID; public string Name; public string CompanionIdentifier; public string DeviceClass; public DeviceInfo Companion; } public int LaunchOnDevice (DeviceInfo device, string appPath, bool waitForUnlock, bool waitForExit) { var args = new List (); args.AddRange ( new [] { "--devname", device.Name, "--launchdev", AppPath, "--sdkroot", Configuration.xcode_root, $"--wait-for-unlock:{(waitForUnlock ? "yes" : "no")}", $"--wait-for-exit:{(waitForExit ? "yes" : "no")}", } ); AddVerbosity (args); return Execute (args); } public int InstallOnDevice (DeviceInfo device, string appPath, string devicetype = null) { var args = new List (); args.AddRange ( new [] { "--devname", device.Name, "--installdev", AppPath, "--sdkroot", Configuration.xcode_root, }); AddVerbosity (args); if (devicetype != null) { args.Add ("--device"); args.Add (devicetype); } return Execute (args); } // The default is to build for the simulator public override int Execute () { if (!Action.HasValue) Action = MTouchAction.BuildSim; return base.Execute (); } public int Execute (MTouchAction action) { this.Action = action; return base.Execute (); } // The default is to build for the simulator public override void AssertExecute (string message = null) { if (!Action.HasValue) Action = MTouchAction.BuildSim; base.AssertExecute (message); } public void AssertExecute (MTouchAction action, string message = null) { this.Action = action; base.AssertExecute (message); } public void AssertExecuteFailure (MTouchAction action, string message = null) { Action = action; NUnit.Framework.Assert.AreEqual (1, Execute (), message); } // Assert that none of the files in the app has changed (except 'except' files) public void AssertNoneModified (DateTime timestamp, string message, params string [] except) { var failed = new List (); var files = Directory.EnumerateFiles (AppPath, "*", SearchOption.AllDirectories); foreach (var file in files) { var info = new FileInfo (file); if (info.LastWriteTime > timestamp) { if (except != null && except.Contains (Path.GetFileName (file))) { Console.WriteLine ("SKIP: {0} modified: {1} > {2}", file, info.LastWriteTime, timestamp); } else { failed.Add (string.Format ("{0} is modified, timestamp: {1} > {2}", file, info.LastWriteTime, timestamp)); Console.WriteLine ("FAIL: {0} modified: {1} > {2}", file, info.LastWriteTime, timestamp); } } else { Console.WriteLine ("{0} not modified ted: {1} <= {2}", file, info.LastWriteTime, timestamp); } } Assert.IsEmpty (failed, message); } // Assert that all of the files in the app has changed (except 'except' files) public void AssertAllModified (DateTime timestamp, string message, params string [] except) { var failed = new List (); var files = Directory.EnumerateFiles (AppPath, "*", SearchOption.AllDirectories); foreach (var file in files) { var info = new FileInfo (file); if (info.LastWriteTime <= timestamp) { if (except != null && except.Contains (Path.GetFileName (file))) { Console.WriteLine ("SKIP: {0} not modified: {1} <= {2}", file, info.LastWriteTime, timestamp); } else { failed.Add (string.Format ("{0} is not modified, timestamp: {1} <= {2}", file, info.LastWriteTime, timestamp)); Console.WriteLine ("FAIL: {0} not modified: {1} <= {2}", file, info.LastWriteTime, timestamp); } } else { Console.WriteLine ("{0} modified (as expected): {1} > {2}", file, info.LastWriteTime, timestamp); } } Assert.IsEmpty (failed, message); } // Asserts that the given files were modified. public void AssertModified (DateTime timestamp, string message, params string [] files) { Assert.IsNotEmpty (files); var failed = new List (); var fs = Directory.EnumerateFiles (AppPath, "*", SearchOption.AllDirectories); foreach (var file in fs) { if (!files.Contains (Path.GetFileName (file))) continue; var info = new FileInfo (file); if (info.LastWriteTime < timestamp) { failed.Add (string.Format ("{0} is not modified, timestamp: {1} < {2}", file, info.LastWriteTime, timestamp)); Console.WriteLine ("FAIL: {0} not modified: {1} < {2}", file, info.LastWriteTime, timestamp); } else { Console.WriteLine ("{0} modified (as expected): {1} >= {2}", file, info.LastWriteTime, timestamp); } } Assert.IsEmpty (failed, message); } protected override string GetDefaultAbi() { var isDevice = false; switch (Action.Value) { case MTouchAction.None: break; case MTouchAction.BuildDev: isDevice = true; break; case MTouchAction.BuildSim: isDevice = false; break; case MTouchAction.LaunchSim: isDevice = false; break; default: throw new NotImplementedException (); } switch (Profile) { case Profile.iOS: return null; // not required case Profile.tvOS: return isDevice ? "arm64" : "x86_64"; case Profile.watchOS: return isDevice ? "armv7k" : "i386"; default: throw new NotImplementedException (); } } protected override void BuildArguments (IList sb) { base.BuildArguments (sb); switch (Action.Value) { case MTouchAction.None: break; case MTouchAction.BuildDev: MTouch.AssertDeviceAvailable (); if (AppPath == null) throw new Exception ("No AppPath specified."); sb.Add ("--dev"); sb.Add (AppPath); break; case MTouchAction.BuildSim: if (AppPath == null) throw new Exception ("No AppPath specified."); sb.Add ("--sim"); sb.Add (AppPath); break; case MTouchAction.LaunchSim: if (AppPath == null) throw new Exception ("No AppPath specified."); sb.Add ("--launchsim"); sb.Add (AppPath); break; default: throw new NotImplementedException (); } if (FastDev.HasValue && FastDev.Value) sb.Add ("--fastdev"); if (PackageMdb.HasValue) sb.Add ($"--package-mdb:{(PackageMdb.Value ? "true" : "false")}"); if (NoStrip.HasValue && NoStrip.Value) sb.Add ("--nostrip"); if (NoSymbolStrip != null) { if (NoSymbolStrip.Length == 0) { sb.Add ("--nosymbolstrip"); } else { sb.Add ($"--nosymbolstrip:{NoSymbolStrip}"); } } if (!string.IsNullOrEmpty (SymbolList)) sb.Add ($"--symbollist={SymbolList}"); if (MSym.HasValue) sb.Add ($"--msym:{(MSym.Value ? "true" : "false")}"); if (DSym.HasValue) sb.Add ($"--dsym:{(DSym.Value ? "true" : "false")}"); foreach (var appext in AppExtensions) { sb.Add ($"--app-extension"); sb.Add (appext.AppPath); } foreach (var framework in Frameworks) { sb.Add ($"--framework"); sb.Add (framework); } if (!string.IsNullOrEmpty (Mono)) sb.Add ($"--mono:{Mono}"); if (Dlsym.HasValue) sb.Add ($"--dlsym:{(Dlsym.Value ? "true" : "false")}"); if (!string.IsNullOrEmpty (Executable)) { sb.Add ("--executable"); sb.Add (Executable); } switch (SymbolMode) { case MTouchSymbolMode.Ignore: sb.Add ("--dynamic-symbol-mode=ignore"); break; case MTouchSymbolMode.Code: sb.Add ("--dynamic-symbol-mode=code"); break; case MTouchSymbolMode.Default: sb.Add ("--dynamic-symbol-mode=default"); break; case MTouchSymbolMode.Linker: sb.Add ("--dynamic-symbol-mode=linker"); break; case MTouchSymbolMode.Unspecified: break; default: throw new NotImplementedException (); } if (NoFastSim.HasValue && NoFastSim.Value) sb.Add ("--nofastsim"); if (!string.IsNullOrEmpty (Device)) sb.Add ($"--device:{Device}"); if (!string.IsNullOrEmpty (LLVMOptimizations)) sb.Add ($"--llvm-opt={LLVMOptimizations}"); if (Bitcode != MTouchBitcode.Unspecified) sb.Add ($"--bitcode:{Bitcode.ToString ().ToLower ()}"); foreach (var abt in AssemblyBuildTargets) sb.Add ($"--assembly-build-target={abt}"); if (!string.IsNullOrEmpty (AotArguments)) sb.Add ($"--aot:{AotArguments}"); if (!string.IsNullOrEmpty (AotOtherArguments)) sb.Add ($"--aot-options:{AotOtherArguments}"); } XmlDocument FetchDeviceList (bool allowCache = true) { if (device_list_cache == null || !allowCache) { var output_file = Path.GetTempFileName (); try { if (Execute (new [] { "--listdev", output_file, "--sdkroot", Configuration.xcode_root }) != 0) throw new Exception ("Failed to list devices."); device_list_cache = new XmlDocument (); device_list_cache.Load (output_file); } finally { File.Delete (output_file); } } return device_list_cache; } public List ListDevices () { var rv = new List (); foreach (XmlNode node in FetchDeviceList ().SelectNodes ("//MTouch/Device")) { rv.Add (new DeviceInfo () { UDID = node.SelectSingleNode ("UDID")?.InnerText, Name = node.SelectSingleNode ("Name")?.InnerText, CompanionIdentifier = node.SelectSingleNode ("CompanionIdentifier")?.InnerText, DeviceClass = node.SelectSingleNode ("DeviceClass")?.InnerText, }); } foreach (var device in rv) { if (string.IsNullOrEmpty (device.CompanionIdentifier)) continue; device.Companion = rv.FirstOrDefault ((d) => d.UDID == device.CompanionIdentifier); } return rv; } public IEnumerable FindAvailableDevices (string [] deviceClasses) { return ListDevices ().Where ((info) => deviceClasses.Contains (info.DeviceClass)); } public string NativeExecutablePath { get { return Path.Combine (AppPath, Path.GetFileNameWithoutExtension (RootAssembly)); } } public static string CreatePlist (Profile profile, string appName) { string plist = null; switch (profile) { case Profile.iOS: plist = string.Format (@" CFBundleDisplayName {0} CFBundleIdentifier com.xamarin.{0} CFBundleExecutable {0} MinimumOSVersion {1} UIDeviceFamily 1 2 UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight ", appName, MTouch.GetSdkVersion (profile)); break; case Profile.tvOS: plist = string.Format (@" CFBundleDisplayName Extensiontest CFBundleIdentifier com.xamarin.{0} CFBundleExecutable {0} MinimumOSVersion {1} UIDeviceFamily 3 ", appName, MTouch.GetSdkVersion (profile)); break; default: throw new Exception ("Profile not specified."); } return plist; } public string CreateTemporarySatelliteAssembly (string culture = "en-AU") { var asm_dir = Path.Combine (Path.GetDirectoryName (RootAssembly), culture); Directory.CreateDirectory (asm_dir); var asm_name = Path.GetFileNameWithoutExtension (RootAssembly) + ".resources.dll"; // Cheat a bit, by compiling a normal assembly with code instead of creating a resource assembly return MTouch.CompileTestAppLibrary (asm_dir, "class X {}", appName: Path.GetFileNameWithoutExtension (asm_name)); } public override void CreateTemporaryApp (Profile profile, string appName = "testApp", string code = null, IList extraArgs = null, string extraCode = null, string usings = null) { Profile = profile; CreateTemporaryApp (appName: appName, code: code, extraArgs: extraArgs, extraCode: extraCode, usings: usings); } public void CreateTemporaryApp () { CreateTemporaryApp (false, "testApp", null, (IList) null); } public void CreateTemporaryApp (bool hasPlist = false, string appName = "testApp", string code = null, IList extraArgs = null, string extraCode = null, string usings = null) { string testDir; if (RootAssembly == null) { testDir = CreateTemporaryDirectory (); } else { // We're rebuilding an existing executable, so just reuse that testDir = Path.GetDirectoryName (RootAssembly); } var app = AppPath ?? Path.Combine (testDir, appName + ".app"); Directory.CreateDirectory (app); AppPath = app; RootAssembly = CompileTestAppExecutable (testDir, code, extraArgs, Profile, appName, extraCode, usings); if (hasPlist) File.WriteAllText (Path.Combine (app, "Info.plist"), CreatePlist (Profile, appName)); } public void CreateTemporaryServiceExtension (string code = null, string extraCode = null, IList extraArgs = null, string appName = "testServiceExtension") { string testDir; if (RootAssembly == null) { testDir = CreateTemporaryDirectory (); } else { // We're rebuilding an existing executable, so just reuse that testDir = Path.GetDirectoryName (RootAssembly); } var app = AppPath ?? Path.Combine (testDir, $"{appName}.appex"); Directory.CreateDirectory (app); if (code == null) { code = @"using UserNotifications; [Foundation.Register (""NotificationService"")] public partial class NotificationService : UNNotificationServiceExtension { protected NotificationService (System.IntPtr handle) : base (handle) {} }"; } if (extraCode != null) code += extraCode; AppPath = app; Extension = true; RootAssembly = MTouch.CompileTestAppLibrary (testDir, code: code, profile: Profile, extraArgs: extraArgs, appName: appName); var info_plist = @" CFBundleDisplayName serviceextension CFBundleName serviceextension CFBundleIdentifier com.xamarin.testapp.serviceextension CFBundleDevelopmentRegion en CFBundleInfoDictionaryVersion 7.0 CFBundlePackageType XPC! CFBundleShortVersionString 1.0 CFBundleVersion 1.0 MinimumOSVersion 10.0 NSExtension NSExtensionPointIdentifier com.apple.usernotifications.service NSExtensionPrincipalClass NotificationService "; var plist_path = Path.Combine (app, "Info.plist"); if (!File.Exists (plist_path) || File.ReadAllText (plist_path) != info_plist) File.WriteAllText (plist_path, info_plist); } public void CreateTemporaryTodayExtension (string code = null, string extraCode = null, IList extraArgs = null, string appName = "testTodayExtension") { string testDir; if (RootAssembly == null) { testDir = CreateTemporaryDirectory (); } else { // We're rebuilding an existing executable, so just reuse that testDir = Path.GetDirectoryName (RootAssembly); } var app = AppPath ?? Path.Combine (testDir, $"{appName}.appex"); Directory.CreateDirectory (app); if (code == null) { code = @"using System; using Foundation; using NotificationCenter; using UIKit; public partial class TodayViewController : UIViewController, INCWidgetProviding { public TodayViewController (IntPtr handle) : base (handle) { } [Export (""widgetPerformUpdateWithCompletionHandler:"")] public void WidgetPerformUpdate (Action completionHandler) { completionHandler (NCUpdateResult.NewData); } } "; } if (extraCode != null) code += extraCode; AppPath = app; Extension = true; RootAssembly = MTouch.CompileTestAppLibrary (testDir, code: code, profile: Profile, extraArgs: extraArgs, appName: appName); var info_plist = // FIXME: this includes a NSExtensionMainStoryboard key which points to a non-existent storyboard. This won't matter as long as we're only building, and not running the extension. @" CFBundleDisplayName todayextension CFBundleName todayextension CFBundleIdentifier com.xamarin.testapp.todayextension CFBundleDevelopmentRegion en CFBundleInfoDictionaryVersion 6.0 CFBundlePackageType XPC! CFBundleShortVersionString 1.0 CFBundleVersion 1.0 MinimumOSVersion 10.0 NSExtension NSExtensionPointIdentifier widget-extension NSExtensionMainStoryboard MainInterface "; var plist_path = Path.Combine (app, "Info.plist"); if (!File.Exists (plist_path) || File.ReadAllText (plist_path) != info_plist) File.WriteAllText (plist_path, info_plist); } public void CreateTemporaryWatchKitExtension (string code = null, string extraCode = null, IList extraArgs = null) { string testDir; if (RootAssembly == null) { testDir = CreateTemporaryDirectory (); } else { // We're rebuilding an existing executable, so just reuse that directory testDir = Path.GetDirectoryName (RootAssembly); } var app = AppPath ?? Path.Combine (testDir, "testApp.appex"); Directory.CreateDirectory (app); if (code == null) { code = @"using WatchKit; public partial class NotificationController : WKUserNotificationInterfaceController { protected NotificationController (System.IntPtr handle) : base (handle) {} }"; } if (extraCode != null) code += extraCode; AppPath = app; Extension = true; RootAssembly = MTouch.CompileTestAppLibrary (testDir, code: code, extraArgs: extraArgs, profile: Profile); File.WriteAllText (Path.Combine (app, "Info.plist"), @" CFBundleDisplayName testapp CFBundleName testapp CFBundleIdentifier com.xamarin.testapp CFBundleDevelopmentRegion en CFBundleVersion 1.0 MinimumOSVersion 2.0 NSExtension NSExtensionAttributes WKAppBundleIdentifier com.xamarin.testapp.watchkitapp NSExtensionPointIdentifier com.apple.watchkit RemoteInterfacePrincipleClass InterfaceController CFBundleShortVersionString 1.0 "); } public void CreateTemporaryWatchOSIntentsExtension (string code = null, string appName = "intentsExtension") { string testDir; if (RootAssembly == null) { testDir = CreateTemporaryDirectory (); } else { // We're rebuilding an existing executable, so just reuse that directory testDir = Path.GetDirectoryName (RootAssembly); } var app = AppPath ?? Path.Combine (testDir, $"{appName}.appex"); Directory.CreateDirectory (app); if (code == null) { code = @" using System; using Foundation; using Intents; using WatchKit; [Register (""IntentHandler"")] public class IntentHandler : INExtension, IINRidesharingDomainHandling { protected IntentHandler (System.IntPtr handle) : base (handle) {} public void HandleRequestRide (INRequestRideIntent intent, Action completion) { } public void HandleListRideOptions (INListRideOptionsIntent intent, Action completion) { } public void HandleRideStatus (INGetRideStatusIntent intent, Action completion) { } public void StartSendingUpdates (INGetRideStatusIntent intent, IINGetRideStatusIntentResponseObserver observer) { } public void StopSendingUpdates (INGetRideStatusIntent intent) { } }"; } AppPath = app; Extension = true; RootAssembly = MTouch.CompileTestAppLibrary (testDir, code: code, profile: Profile.watchOS, appName: appName); File.WriteAllText (Path.Combine (app, "Info.plist"), $@" BuildMachineOSBuild 17E199 CFBundleDevelopmentRegion en CFBundleDisplayName {appName} CFBundleExecutable {appName} CFBundleIdentifier com.xamarin.testapp.watchkitapp.watchkitextension.intentswatch CFBundleInfoDictionaryVersion 6.0 CFBundleName {appName} CFBundlePackageType XPC! CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 3.2 NSAppTransportSecurity NSAllowsArbitraryLoads NSExtension NSExtensionAttributes IntentsRestrictedWhileLocked IntentsSupported INRequestRideIntent INListRideOptionsIntent INGetRideStatusIntent NSExtensionPointIdentifier com.apple.intents-service NSExtensionPrincipalClass IntentHandler UIDeviceFamily 4 "); } public void CreateTemporaryApp_LinkWith () { AppPath = CreateTemporaryAppDirectory (); RootAssembly = MTouch.CompileTestAppExecutableLinkWith (Path.GetDirectoryName (AppPath), profile: Profile); } public string CreateTemporaryAppDirectory () { if (AppPath != null) throw new Exception ("There already is an App directory"); AppPath = Path.Combine (CreateTemporaryDirectory (), "testApp.app"); Directory.CreateDirectory (AppPath); return AppPath; } void IDisposable.Dispose () { } public IEnumerable NativeSymbolsInExecutable { get { return GetNativeSymbolsInExecutable (NativeExecutablePath); } } public static IEnumerable GetNativeSymbolsInExecutable (string executable, string arch = null) { var args = new List (); if (arch != null) { args.Add ("-arch"); args.Add (arch); } args.Add ("-gUj"); args.Add (executable); IEnumerable rv = ExecutionHelper.Execute ("nm", args, hide_output: true).Split ('\n'); rv = rv.Where ((v) => { if (string.IsNullOrEmpty (v)) return false; if (v.StartsWith (executable, StringComparison.Ordinal) && v.EndsWith (":", StringComparison.Ordinal)) return false; if (v == "no symbols") return false; return true; }); return rv; } protected override string ToolPath { get { return Configuration.MtouchPath; } } protected override string MessagePrefix { get { return "MT"; } } public override string GetAppAssembliesDirectory () { return AppPath; } } }