diff --git a/Xamarin.MacDev/NullableAttributes.cs b/Xamarin.MacDev/NullableAttributes.cs index 66091d9..74a83ba 100644 --- a/Xamarin.MacDev/NullableAttributes.cs +++ b/Xamarin.MacDev/NullableAttributes.cs @@ -42,3 +42,12 @@ internal sealed class NotNullIfNotNullAttribute : Attribute { } } #endif // !NET + +#if NETSTANDARD2_0 +namespace System.Runtime.CompilerServices { + // Required polyfill for C# 9 records on netstandard2.0 targets. + // Enables init-only setters (C# 9 'init' keyword) in netstandard2.0 projects. + // See: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/records + internal static class IsExternalInit { } +} +#endif // NETSTANDARD2_0 diff --git a/Xamarin.MacDev/PrivacyPermission.cs b/Xamarin.MacDev/PrivacyPermission.cs new file mode 100644 index 0000000..41123a1 --- /dev/null +++ b/Xamarin.MacDev/PrivacyPermission.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Privacy service categories for xcrun simctl privacy. +/// +public enum PrivacyPermission { + All, + Calendar, + ContactsLimited, + Contacts, + Location, + LocationAlways, + PhotosAdd, + Photos, + MediaLibrary, + Microphone, + Motion, + Reminders, + Siri, +} diff --git a/Xamarin.MacDev/SimCtl.cs b/Xamarin.MacDev/SimCtl.cs index 517549e..b0ae6f2 100644 --- a/Xamarin.MacDev/SimCtl.cs +++ b/Xamarin.MacDev/SimCtl.cs @@ -16,7 +16,7 @@ namespace Xamarin.MacDev; /// public class SimCtl { - static readonly string XcrunPath = "/usr/bin/xcrun"; + internal static readonly string XcrunPath = "/usr/bin/xcrun"; readonly ICustomLogger log; diff --git a/Xamarin.MacDev/SimulatorAppearance.cs b/Xamarin.MacDev/SimulatorAppearance.cs new file mode 100644 index 0000000..5a27172 --- /dev/null +++ b/Xamarin.MacDev/SimulatorAppearance.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// The UI appearance (theme) of a simulator. +/// Used with xcrun simctl ui <udid> appearance. +/// +public enum SimulatorAppearance { + Light, + Dark, +} diff --git a/Xamarin.MacDev/SimulatorLocation.cs b/Xamarin.MacDev/SimulatorLocation.cs new file mode 100644 index 0000000..5536876 --- /dev/null +++ b/Xamarin.MacDev/SimulatorLocation.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Wraps xcrun simctl location operations for setting, clearing, +/// and simulating GPS routes on a simulator. +/// +public class SimulatorLocation { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorLocation (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Sets the simulated GPS location on the simulator. + /// Wraps xcrun simctl location <udid> set <lat>,<lng>. + /// + public bool Set (string udidOrName, double latitude, double longitude) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var coords = string.Format (CultureInfo.InvariantCulture, "{0},{1}", latitude, longitude); + var result = simctl.Run ("location", udidOrName, "set", coords); + var success = result is not null; + if (success) + log.LogInfo ("simctl location set '{0}' to {1} succeeded.", udidOrName, coords); + return success; + } + + /// + /// Clears the simulated GPS location on the simulator. + /// Wraps xcrun simctl location <udid> clear. + /// + public bool Clear (string udidOrName) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var result = simctl.Run ("location", udidOrName, "clear"); + var success = result is not null; + if (success) + log.LogInfo ("simctl location clear '{0}' succeeded.", udidOrName); + return success; + } + + /// + /// Runs a GPX route simulation on the simulator. + /// Wraps xcrun simctl location <udid> run <gpxPath>. + /// + public bool Run (string udidOrName, string gpxPath) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (gpxPath)) + throw new ArgumentException ("GPX path must not be null or empty.", nameof (gpxPath)); + + var result = simctl.Run ("location", udidOrName, "run", gpxPath); + var success = result is not null; + if (success) + log.LogInfo ("simctl location run '{0}' with '{1}' succeeded.", udidOrName, gpxPath); + return success; + } +} diff --git a/Xamarin.MacDev/SimulatorPrivacy.cs b/Xamarin.MacDev/SimulatorPrivacy.cs new file mode 100644 index 0000000..12aef8f --- /dev/null +++ b/Xamarin.MacDev/SimulatorPrivacy.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Wraps xcrun simctl privacy operations for granting, revoking, +/// and resetting simulator privacy permissions. +/// +public class SimulatorPrivacy { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorPrivacy (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Grants a privacy permission for all apps or a specific bundle on the simulator. + /// Wraps xcrun simctl privacy <udid> grant <service> [bundleId]. + /// + public bool Grant (string udidOrName, PrivacyPermission permission, string? bundleIdentifier = null) + { + return RunPrivacy ("grant", udidOrName, permission, bundleIdentifier); + } + + /// + /// Revokes a privacy permission for all apps or a specific bundle on the simulator. + /// Wraps xcrun simctl privacy <udid> revoke <service> [bundleId]. + /// + public bool Revoke (string udidOrName, PrivacyPermission permission, string? bundleIdentifier = null) + { + return RunPrivacy ("revoke", udidOrName, permission, bundleIdentifier); + } + + /// + /// Resets a privacy permission for all apps or a specific bundle on the simulator. + /// Wraps xcrun simctl privacy <udid> reset <service> [bundleId]. + /// + public bool Reset (string udidOrName, PrivacyPermission permission, string? bundleIdentifier = null) + { + return RunPrivacy ("reset", udidOrName, permission, bundleIdentifier); + } + + bool RunPrivacy (string action, string udidOrName, PrivacyPermission permission, string? bundleIdentifier) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var service = ToSimctlServiceName (permission); + + string? result; + if (!string.IsNullOrWhiteSpace (bundleIdentifier)) + result = simctl.Run ("privacy", udidOrName, action, service, bundleIdentifier!); + else + result = simctl.Run ("privacy", udidOrName, action, service); + + var success = result is not null; + if (success) + log.LogInfo ("simctl privacy {0} {1} {2} succeeded.", udidOrName, action, service); + return success; + } + + public static string ToSimctlServiceName (PrivacyPermission permission) + { + return permission switch { + PrivacyPermission.All => "all", + PrivacyPermission.Calendar => "calendar", + PrivacyPermission.ContactsLimited => "contacts-limited", + PrivacyPermission.Contacts => "contacts", + PrivacyPermission.Location => "location", + PrivacyPermission.LocationAlways => "location-always", + PrivacyPermission.PhotosAdd => "photos-add", + PrivacyPermission.Photos => "photos", + PrivacyPermission.MediaLibrary => "media-library", + PrivacyPermission.Microphone => "microphone", + PrivacyPermission.Motion => "motion", + PrivacyPermission.Reminders => "reminders", + PrivacyPermission.Siri => "siri", + _ => throw new ArgumentOutOfRangeException (nameof (permission), permission, null), + }; + } +} diff --git a/Xamarin.MacDev/SimulatorScreenCapture.cs b/Xamarin.MacDev/SimulatorScreenCapture.cs new file mode 100644 index 0000000..19a6361 --- /dev/null +++ b/Xamarin.MacDev/SimulatorScreenCapture.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Wraps xcrun simctl io operations for taking screenshots and recording video +/// from a simulator. +/// +public class SimulatorScreenCapture { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorScreenCapture (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Takes a screenshot of the simulator and saves it to . + /// Wraps xcrun simctl io <udid> screenshot [--type=png|jpeg|tiff|bmp] <path>. + /// + public bool Screenshot (string udidOrName, string outputPath, ScreenshotFormat format = ScreenshotFormat.Png) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (outputPath)) + throw new ArgumentException ("Output path must not be null or empty.", nameof (outputPath)); + + var formatArg = "--type=" + ToSimctlFormatName (format); + var result = simctl.Run ("io", udidOrName, "screenshot", formatArg, outputPath); + var success = result is not null; + if (success) + log.LogInfo ("simctl io screenshot '{0}' to '{1}' succeeded.", udidOrName, outputPath); + return success; + } + + /// + /// Starts a video recording of the simulator. Dispose the returned handle to stop recording. + /// Wraps xcrun simctl io <udid> recordVideo [options] <path>. + /// + /// + /// A disposable handle; call to stop the recording. + /// Returns null if xcrun cannot be started. + /// + public IDisposable? StartRecording (string udidOrName, string outputPath, RecordingOptions? options = null) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (outputPath)) + throw new ArgumentException ("Output path must not be null or empty.", nameof (outputPath)); + + if (!File.Exists (SimCtl.XcrunPath)) { + log.LogInfo ("xcrun not found at '{0}'.", SimCtl.XcrunPath); + return null; + } + + var simctlArgs = BuildRecordArgs (udidOrName, outputPath, options); + + // Build the full argument list: "simctl io recordVideo [opts] " + var allArgs = new string [simctlArgs.Length + 1]; + allArgs [0] = "simctl"; + Array.Copy (simctlArgs, 0, allArgs, 1, simctlArgs.Length); + + log.LogInfo ("Executing: {0} {1}", SimCtl.XcrunPath, string.Join (" ", allArgs)); + + var psi = new ProcessStartInfo (SimCtl.XcrunPath) { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = true, + }; + +#if NETSTANDARD2_0 + psi.Arguments = QuoteArguments (allArgs); +#else + foreach (var arg in allArgs) + psi.ArgumentList.Add (arg); +#endif + + Process? process = null; + try { + process = new Process { StartInfo = psi }; + process.ErrorDataReceived += (_, e) => { + if (e.Data is not null) + log.LogInfo ("simctl io recordVideo stderr: {0}", e.Data); + }; + process.Start (); + process.BeginErrorReadLine (); + log.LogInfo ("simctl io recordVideo started for '{0}'.", udidOrName); + return new VideoRecordingSession (process, log); + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not start xcrun simctl io recordVideo: {0}", ex.Message); + process?.Dispose (); + return null; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not start xcrun simctl io recordVideo: {0}", ex.Message); + process?.Dispose (); + return null; + } + } + + static string [] BuildRecordArgs (string udidOrName, string outputPath, RecordingOptions? options) + { + var args = new List { "io", udidOrName, "recordVideo" }; + + if (options?.Format is { } fmt) + args.Add ("--type=" + ToSimctlVideoFormatName (fmt)); + + if (options?.Force == true) + args.Add ("--force"); + + args.Add (outputPath); + return args.ToArray (); + } + + public static string ToSimctlFormatName (ScreenshotFormat format) + { + return format switch { + ScreenshotFormat.Png => "png", + ScreenshotFormat.Jpeg => "jpeg", + ScreenshotFormat.Tiff => "tiff", + ScreenshotFormat.Bmp => "bmp", + _ => throw new ArgumentOutOfRangeException (nameof (format), format, null), + }; + } + + public static string ToSimctlVideoFormatName (VideoRecordingFormat format) + { + return format switch { + VideoRecordingFormat.Mp4 => "mp4", + VideoRecordingFormat.H264 => "h264", + VideoRecordingFormat.Fmp4 => "fmp4", + VideoRecordingFormat.Gif => "gif", + _ => throw new ArgumentOutOfRangeException (nameof (format), format, null), + }; + } + +#if NETSTANDARD2_0 + static string QuoteArguments (string [] arguments) + { + if (arguments.Length == 0) + return string.Empty; + + var sb = new System.Text.StringBuilder (); + for (int i = 0; i < arguments.Length; i++) { + if (i > 0) + sb.Append (' '); + + var arg = arguments [i]; + if (arg.Length > 0 && arg.IndexOfAny (new [] { ' ', '\t', '"', '\\' }) < 0) { + sb.Append (arg); + } else { + sb.Append ('"'); + sb.Append (arg.Replace ("\\", "\\\\").Replace ("\"", "\\\"")); + sb.Append ('"'); + } + } + return sb.ToString (); + } +#endif + + /// + /// Disposable handle that terminates a running simctl io recordVideo process + /// when disposed. + /// + sealed class VideoRecordingSession : IDisposable { + + const int SIGINT = 2; + + readonly Process process; + readonly ICustomLogger log; + bool disposed; + + public VideoRecordingSession (Process process, ICustomLogger log) + { + this.process = process; + this.log = log; + } + + public void Dispose () + { + if (disposed) + return; + + disposed = true; + + try { + if (!process.HasExited) { + // Send SIGINT for graceful shutdown so simctl can flush and write + // the video file trailer. Using Kill() sends SIGKILL, which + // terminates immediately and produces a corrupt output file. + if (kill (process.Id, SIGINT) != 0 || !process.WaitForExit (5000)) { + process.Kill (); + process.WaitForExit (5000); + } + log.LogInfo ("simctl io recordVideo process stopped."); + } + } catch (InvalidOperationException) { + // Process already exited + } catch (System.ComponentModel.Win32Exception) { + // Cannot kill process (e.g. access denied) + } finally { + process.Dispose (); + } + } + + [DllImport ("/usr/lib/libc.dylib", SetLastError = true)] + static extern int kill (int pid, int sig); + } +} diff --git a/Xamarin.MacDev/SimulatorScreenCaptureTypes.cs b/Xamarin.MacDev/SimulatorScreenCaptureTypes.cs new file mode 100644 index 0000000..b0abf39 --- /dev/null +++ b/Xamarin.MacDev/SimulatorScreenCaptureTypes.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Screenshot image format for xcrun simctl io screenshot. +/// +public enum ScreenshotFormat { + Png, + Jpeg, + Tiff, + Bmp, +} + +/// +/// Video recording format for xcrun simctl io recordVideo. +/// +public enum VideoRecordingFormat { + Mp4, + H264, + Fmp4, + Gif, +} + +/// +/// Options for xcrun simctl io recordVideo. +/// +public class RecordingOptions { + + /// The output video format. Defaults to mp4 when null. + public VideoRecordingFormat? Format { get; set; } + + /// When true, passes --force to overwrite an existing file. + public bool Force { get; set; } +} diff --git a/Xamarin.MacDev/SimulatorService.cs b/Xamarin.MacDev/SimulatorService.cs index 33f48b6..6cb2fc3 100644 --- a/Xamarin.MacDev/SimulatorService.cs +++ b/Xamarin.MacDev/SimulatorService.cs @@ -15,7 +15,7 @@ namespace Xamarin.MacDev; /// Operation patterns validated against Redth/AppleDev.Tools SimCtl and /// ClientTools.Platform RemoteSimulatorValidator. /// -public class SimulatorService { +public partial class SimulatorService { readonly ICustomLogger log; readonly SimCtl simctl; diff --git a/Xamarin.MacDev/SimulatorServiceExtras.cs b/Xamarin.MacDev/SimulatorServiceExtras.cs new file mode 100644 index 0000000..10e117b --- /dev/null +++ b/Xamarin.MacDev/SimulatorServiceExtras.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +#nullable enable + +namespace Xamarin.MacDev; + +public partial class SimulatorService { + + SimulatorPrivacy? privacyService; + SimulatorStatusBar? statusBarService; + SimulatorLocation? locationService; + SimulatorScreenCapture? screenCaptureService; + + /// + /// Provides xcrun simctl privacy operations (grant, revoke, reset). + /// + public SimulatorPrivacy Privacy => privacyService ??= new SimulatorPrivacy (log, simctl); + + /// + /// Provides xcrun simctl status_bar operations (override, clear). + /// + public SimulatorStatusBar StatusBar => statusBarService ??= new SimulatorStatusBar (log, simctl); + + /// + /// Provides xcrun simctl location operations (set, clear, run). + /// + public SimulatorLocation Location => locationService ??= new SimulatorLocation (log, simctl); + + /// + /// Provides xcrun simctl io operations (screenshot, recordVideo). + /// + public SimulatorScreenCapture ScreenCapture => screenCaptureService ??= new SimulatorScreenCapture (log, simctl); + + /// + /// Sets the UI appearance (light/dark) of the simulator. + /// Wraps xcrun simctl ui <udid> appearance light|dark. + /// + public bool SetAppearance (string udidOrName, SimulatorAppearance appearance) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var value = appearance == SimulatorAppearance.Dark ? "dark" : "light"; + var result = simctl.Run ("ui", udidOrName, "appearance", value); + var success = result is not null; + if (success) + log.LogInfo ("simctl ui appearance '{0}' set to {1}.", udidOrName, value); + return success; + } + + /// + /// Gets the current UI appearance of the simulator. + /// Wraps xcrun simctl ui <udid> appearance. + /// Returns null if the query fails or the output is unrecognised. + /// + public SimulatorAppearance? GetAppearance (string udidOrName) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var output = simctl.Run ("ui", udidOrName, "appearance"); + if (output is null) + return null; + + var trimmed = output.Trim (); + if (string.Equals (trimmed, "dark", StringComparison.OrdinalIgnoreCase)) + return SimulatorAppearance.Dark; + if (string.Equals (trimmed, "light", StringComparison.OrdinalIgnoreCase)) + return SimulatorAppearance.Light; + + log.LogInfo ("Unrecognised appearance value from simctl: '{0}'.", trimmed); + return null; + } + + /// + /// Opens a URL on the simulator, triggering the registered URL handler or browser. + /// Wraps xcrun simctl openurl <udid> <url>. + /// + public bool OpenUrl (string udidOrName, string url) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (url)) + throw new ArgumentException ("URL must not be null or empty.", nameof (url)); + + var result = simctl.Run ("openurl", udidOrName, url); + var success = result is not null; + if (success) + log.LogInfo ("simctl openurl '{0}' succeeded.", udidOrName); + return success; + } + + /// + /// Sends a push notification to the simulator. + /// may be a file path to an APNS JSON payload file + /// or a raw JSON string (starting with {), which is written to a temporary file. + /// Wraps xcrun simctl push <udid> <bundleId> <file>. + /// + public bool Push (string udidOrName, string bundleIdentifier, string payloadJsonOrPath) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (bundleIdentifier)) + throw new ArgumentException ("Bundle identifier must not be null or empty.", nameof (bundleIdentifier)); + if (string.IsNullOrWhiteSpace (payloadJsonOrPath)) + throw new ArgumentException ("Payload JSON or path must not be null or empty.", nameof (payloadJsonOrPath)); + + var isInlineJson = payloadJsonOrPath.TrimStart ().StartsWith ("{", StringComparison.Ordinal); + if (isInlineJson) { + var tempPath = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName () + ".json"); + try { + File.WriteAllText (tempPath, payloadJsonOrPath, Encoding.UTF8); + return RunPush (udidOrName, bundleIdentifier, tempPath); + } finally { + try { if (File.Exists (tempPath)) File.Delete (tempPath); } catch (IOException ex) { + log.LogInfo ("Failed to delete temporary push payload file '{0}': {1}", tempPath, ex.Message); + } catch (UnauthorizedAccessException ex) { + log.LogInfo ("Failed to delete temporary push payload file '{0}': {1}", tempPath, ex.Message); + } + } + } + + return RunPush (udidOrName, bundleIdentifier, payloadJsonOrPath); + } + + bool RunPush (string udidOrName, string bundleIdentifier, string payloadPath) + { + var result = simctl.Run ("push", udidOrName, bundleIdentifier, payloadPath); + var success = result is not null; + if (success) + log.LogInfo ("simctl push to '{0}' ({1}) succeeded.", udidOrName, bundleIdentifier); + return success; + } + + /// + /// Adds media files (photos, videos) to the simulator's media library. + /// Wraps xcrun simctl addmedia <udid> <file> .... + /// + public bool AddMedia (string udidOrName, IEnumerable paths) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (paths is null) + throw new ArgumentNullException (nameof (paths)); + + var pathList = new List (paths); + if (pathList.Count == 0) + throw new ArgumentException ("At least one media path must be provided.", nameof (paths)); + + var args = new string [pathList.Count + 2]; + args [0] = "addmedia"; + args [1] = udidOrName; + for (int i = 0; i < pathList.Count; i++) + args [i + 2] = pathList [i]; + + var result = simctl.Run (args); + var success = result is not null; + if (success) + log.LogInfo ("simctl addmedia '{0}' ({1} file(s)) succeeded.", udidOrName, pathList.Count); + return success; + } +} diff --git a/Xamarin.MacDev/SimulatorStatusBar.cs b/Xamarin.MacDev/SimulatorStatusBar.cs new file mode 100644 index 0000000..729c810 --- /dev/null +++ b/Xamarin.MacDev/SimulatorStatusBar.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Battery state values for the simulator status-bar override. +/// Used with xcrun simctl status_bar <udid> override --batteryState. +/// +public enum SimulatorBatteryState { + Charging, + Charged, + Discharging, +} + +/// +/// Data network type values for the simulator status-bar override. +/// Used with xcrun simctl status_bar <udid> override --dataNetwork. +/// +public enum SimulatorDataNetwork { + Wifi, + ThreeG, + FourG, + Lte, + LteA, + LtePlus, + FiveG, + FiveGPlus, + FiveGUc, + FiveGA, +} + +/// +/// Override values for the simulator status bar. +/// All fields are optional; pass only the fields you want to override. +/// +public record StatusBarOverrides ( + string? Time = null, + int? BatteryLevel = null, + SimulatorBatteryState? BatteryState = null, + SimulatorDataNetwork? DataNetwork = null, + int? CellularBars = null, + int? WifiBars = null, + string? OperatorName = null); + +/// +/// Wraps xcrun simctl status_bar operations for overriding and clearing +/// simulator status bar values. +/// +public class SimulatorStatusBar { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorStatusBar (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Overrides status-bar values on the simulator. + /// Wraps xcrun simctl status_bar <udid> override [options]. + /// + public bool Override (string udidOrName, StatusBarOverrides overrides) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (overrides is null) + throw new ArgumentNullException (nameof (overrides)); + + if (overrides.Time is null && !overrides.BatteryLevel.HasValue && + !overrides.BatteryState.HasValue && !overrides.DataNetwork.HasValue && + !overrides.CellularBars.HasValue && !overrides.WifiBars.HasValue && + overrides.OperatorName is null) + throw new ArgumentException ("At least one StatusBarOverrides field must be set.", nameof (overrides)); + + var args = BuildOverrideArgs (udidOrName, overrides); + var result = simctl.Run (args); + var success = result is not null; + if (success) + log.LogInfo ("simctl status_bar override '{0}' succeeded.", udidOrName); + return success; + } + + /// + /// Clears all status-bar overrides on the simulator. + /// Wraps xcrun simctl status_bar <udid> clear. + /// + public bool Clear (string udidOrName) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var result = simctl.Run ("status_bar", udidOrName, "clear"); + var success = result is not null; + if (success) + log.LogInfo ("simctl status_bar clear '{0}' succeeded.", udidOrName); + return success; + } + + static string [] BuildOverrideArgs (string udidOrName, StatusBarOverrides overrides) + { + var args = new List { + "status_bar", udidOrName, "override", + }; + + if (overrides.Time is not null) { + args.Add ("--time"); + args.Add (overrides.Time); + } + + if (overrides.BatteryLevel.HasValue) { + args.Add ("--batteryLevel"); + args.Add (overrides.BatteryLevel.Value.ToString ()); + } + + if (overrides.BatteryState.HasValue) { + args.Add ("--batteryState"); + args.Add (ToSimctlBatteryState (overrides.BatteryState.Value)); + } + + if (overrides.DataNetwork.HasValue) { + args.Add ("--dataNetwork"); + args.Add (ToSimctlDataNetwork (overrides.DataNetwork.Value)); + } + + if (overrides.CellularBars.HasValue) { + args.Add ("--cellularBars"); + args.Add (overrides.CellularBars.Value.ToString ()); + } + + if (overrides.WifiBars.HasValue) { + args.Add ("--wifiBars"); + args.Add (overrides.WifiBars.Value.ToString ()); + } + + if (overrides.OperatorName is not null) { + args.Add ("--operatorName"); + args.Add (overrides.OperatorName); + } + + return args.ToArray (); + } + + public static string ToSimctlBatteryState (SimulatorBatteryState state) + { + return state switch { + SimulatorBatteryState.Charging => "charging", + SimulatorBatteryState.Charged => "charged", + SimulatorBatteryState.Discharging => "discharging", + _ => throw new ArgumentOutOfRangeException (nameof (state), state, null), + }; + } + + public static string ToSimctlDataNetwork (SimulatorDataNetwork network) + { + return network switch { + SimulatorDataNetwork.Wifi => "wifi", + SimulatorDataNetwork.ThreeG => "3g", + SimulatorDataNetwork.FourG => "4g", + SimulatorDataNetwork.Lte => "lte", + SimulatorDataNetwork.LteA => "lte-a", + SimulatorDataNetwork.LtePlus => "lte+", + SimulatorDataNetwork.FiveG => "5g", + SimulatorDataNetwork.FiveGPlus => "5g+", + SimulatorDataNetwork.FiveGUc => "5g-uc", + SimulatorDataNetwork.FiveGA => "5g-a", + _ => throw new ArgumentOutOfRangeException (nameof (network), network, null), + }; + } +} diff --git a/tests/SimulatorServiceExtrasTests.cs b/tests/SimulatorServiceExtrasTests.cs new file mode 100644 index 0000000..3a0f49b --- /dev/null +++ b/tests/SimulatorServiceExtrasTests.cs @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using Xamarin.MacDev; + +#nullable enable + +namespace tests; + +[TestFixture] +public class SimulatorServiceExtrasTests { + + // ── SimulatorService construction ──────────────────────────────────────── + + [Test] + public void Constructor_ThrowsOnNullLogger () + { + Assert.Throws (() => new SimulatorService (null!)); + } + + // ── Privacy service lazy-initialisation ───────────────────────────────── + + [Test] + public void Privacy_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.Privacy, Is.Not.Null); + } + + [Test] + public void Privacy_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + var first = svc.Privacy; + Assert.That (first, Is.SameAs (svc.Privacy)); + } + + [Test] + public void Privacy_Grant_ThrowsOnNullOrEmptyUdid () + { + var p = new SimulatorService (ConsoleLogger.Instance).Privacy; + Assert.Throws (() => p.Grant (null!, PrivacyPermission.Calendar)); + Assert.Throws (() => p.Grant ("", PrivacyPermission.Calendar)); + Assert.Throws (() => p.Grant (" ", PrivacyPermission.Calendar)); + } + + [Test] + public void Privacy_Revoke_ThrowsOnNullOrEmptyUdid () + { + var p = new SimulatorService (ConsoleLogger.Instance).Privacy; + Assert.Throws (() => p.Revoke (null!, PrivacyPermission.Photos)); + Assert.Throws (() => p.Revoke ("", PrivacyPermission.Photos)); + } + + [Test] + public void Privacy_Reset_ThrowsOnNullOrEmptyUdid () + { + var p = new SimulatorService (ConsoleLogger.Instance).Privacy; + Assert.Throws (() => p.Reset (null!, PrivacyPermission.All)); + Assert.Throws (() => p.Reset ("", PrivacyPermission.All)); + } + + [Test] + public void PrivacyPermission_ToSimctlServiceName_ConvertsAllValues () + { + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.All), Is.EqualTo ("all")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Calendar), Is.EqualTo ("calendar")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.ContactsLimited), Is.EqualTo ("contacts-limited")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Contacts), Is.EqualTo ("contacts")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Location), Is.EqualTo ("location")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.LocationAlways), Is.EqualTo ("location-always")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.PhotosAdd), Is.EqualTo ("photos-add")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Photos), Is.EqualTo ("photos")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.MediaLibrary), Is.EqualTo ("media-library")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Microphone), Is.EqualTo ("microphone")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Motion), Is.EqualTo ("motion")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Reminders), Is.EqualTo ("reminders")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Siri), Is.EqualTo ("siri")); + } + + // ── StatusBar service lazy-initialisation ──────────────────────────────── + + [Test] + public void StatusBar_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.StatusBar, Is.Not.Null); + } + + [Test] + public void StatusBar_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + var first = svc.StatusBar; + Assert.That (first, Is.SameAs (svc.StatusBar)); + } + + [Test] + public void StatusBar_Override_ThrowsOnNullOrEmptyUdid () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + var overrides = new StatusBarOverrides (Time: "09:41"); + Assert.Throws (() => sb.Override (null!, overrides)); + Assert.Throws (() => sb.Override ("", overrides)); + } + + [Test] + public void StatusBar_Override_ThrowsOnNullOverrides () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + Assert.Throws (() => sb.Override ("booted", null!)); + } + + [Test] + public void StatusBar_Override_ThrowsOnEmptyOverrides () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + Assert.Throws (() => sb.Override ("booted", new StatusBarOverrides ())); + } + + [Test] + public void StatusBar_Clear_ThrowsOnNullOrEmptyUdid () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + Assert.Throws (() => sb.Clear (null!)); + Assert.Throws (() => sb.Clear ("")); + } + + [Test] + public void StatusBar_BatteryState_ConvertsAllValues () + { + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charging), Is.EqualTo ("charging")); + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charged), Is.EqualTo ("charged")); + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Discharging), Is.EqualTo ("discharging")); + } + + [Test] + public void StatusBar_DataNetwork_ConvertsAllValues () + { + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.Wifi), Is.EqualTo ("wifi")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.ThreeG), Is.EqualTo ("3g")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FourG), Is.EqualTo ("4g")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.Lte), Is.EqualTo ("lte")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.LteA), Is.EqualTo ("lte-a")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.LtePlus), Is.EqualTo ("lte+")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveG), Is.EqualTo ("5g")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveGPlus), Is.EqualTo ("5g+")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveGUc), Is.EqualTo ("5g-uc")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveGA), Is.EqualTo ("5g-a")); + } + + // ── Location service lazy-initialisation ───────────────────────────────── + + [Test] + public void Location_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.Location, Is.Not.Null); + } + + [Test] + public void Location_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + var first = svc.Location; + Assert.That (first, Is.SameAs (svc.Location)); + } + + [Test] + public void Location_Set_ThrowsOnNullOrEmptyUdid () + { + var loc = new SimulatorService (ConsoleLogger.Instance).Location; + Assert.Throws (() => loc.Set (null!, 37.33, -122.03)); + Assert.Throws (() => loc.Set ("", 37.33, -122.03)); + } + + [Test] + public void Location_Clear_ThrowsOnNullOrEmptyUdid () + { + var loc = new SimulatorService (ConsoleLogger.Instance).Location; + Assert.Throws (() => loc.Clear (null!)); + Assert.Throws (() => loc.Clear ("")); + } + + [Test] + public void Location_Run_ThrowsOnNullOrEmptyUdidOrPath () + { + var loc = new SimulatorService (ConsoleLogger.Instance).Location; + Assert.Throws (() => loc.Run (null!, "/tmp/route.gpx")); + Assert.Throws (() => loc.Run ("", "/tmp/route.gpx")); + Assert.Throws (() => loc.Run ("booted", null!)); + Assert.Throws (() => loc.Run ("booted", "")); + } + + // ── ScreenCapture service lazy-initialisation ──────────────────────────── + + [Test] + public void ScreenCapture_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.ScreenCapture, Is.Not.Null); + } + + [Test] + public void ScreenCapture_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + var first = svc.ScreenCapture; + Assert.That (first, Is.SameAs (svc.ScreenCapture)); + } + + [Test] + public void ScreenCapture_Screenshot_ThrowsOnNullOrEmptyUdid () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.Screenshot (null!, "/tmp/out.png")); + Assert.Throws (() => sc.Screenshot ("", "/tmp/out.png")); + } + + [Test] + public void ScreenCapture_Screenshot_ThrowsOnNullOrEmptyPath () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.Screenshot ("booted", null!)); + Assert.Throws (() => sc.Screenshot ("booted", "")); + } + + [Test] + public void ScreenCapture_StartRecording_ThrowsOnNullOrEmptyUdid () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.StartRecording (null!, "/tmp/out.mp4")); + Assert.Throws (() => sc.StartRecording ("", "/tmp/out.mp4")); + } + + [Test] + public void ScreenCapture_StartRecording_ThrowsOnNullOrEmptyPath () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.StartRecording ("booted", null!)); + Assert.Throws (() => sc.StartRecording ("booted", "")); + } + + [Test] + public void ScreenCapture_ScreenshotFormat_ConvertsAllValues () + { + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Png), Is.EqualTo ("png")); + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Jpeg), Is.EqualTo ("jpeg")); + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Tiff), Is.EqualTo ("tiff")); + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Bmp), Is.EqualTo ("bmp")); + } + + [Test] + public void ScreenCapture_VideoRecordingFormat_ConvertsAllValues () + { + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.Mp4), Is.EqualTo ("mp4")); + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.H264), Is.EqualTo ("h264")); + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.Fmp4), Is.EqualTo ("fmp4")); + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.Gif), Is.EqualTo ("gif")); + } + + // ── Direct methods: SetAppearance / GetAppearance ──────────────────────── + + [Test] + public void SetAppearance_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.SetAppearance (null!, SimulatorAppearance.Dark)); + Assert.Throws (() => svc.SetAppearance ("", SimulatorAppearance.Dark)); + } + + [Test] + public void GetAppearance_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.GetAppearance (null!)); + Assert.Throws (() => svc.GetAppearance ("")); + } + + // ── Direct method: OpenUrl ─────────────────────────────────────────────── + + [Test] + public void OpenUrl_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.OpenUrl (null!, "https://example.com")); + Assert.Throws (() => svc.OpenUrl ("", "https://example.com")); + } + + [Test] + public void OpenUrl_ThrowsOnNullOrEmptyUrl () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.OpenUrl ("booted", null!)); + Assert.Throws (() => svc.OpenUrl ("booted", "")); + } + + // ── Direct method: Push ────────────────────────────────────────────────── + + [Test] + public void Push_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.Push (null!, "com.example.app", "{}")); + Assert.Throws (() => svc.Push ("", "com.example.app", "{}")); + } + + [Test] + public void Push_ThrowsOnNullOrEmptyBundleId () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.Push ("booted", null!, "{}")); + Assert.Throws (() => svc.Push ("booted", "", "{}")); + } + + [Test] + public void Push_ThrowsOnNullOrEmptyPayload () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.Push ("booted", "com.example.app", null!)); + Assert.Throws (() => svc.Push ("booted", "com.example.app", "")); + } + + // ── Direct method: AddMedia ────────────────────────────────────────────── + + [Test] + public void AddMedia_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.AddMedia (null!, new [] { "/tmp/photo.png" })); + Assert.Throws (() => svc.AddMedia ("", new [] { "/tmp/photo.png" })); + } + + [Test] + public void AddMedia_ThrowsOnNullPaths () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.AddMedia ("booted", null!)); + } + + [Test] + public void AddMedia_ThrowsOnEmptyPathsList () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.AddMedia ("booted", new List ())); + } + + // ── StatusBarOverrides record ───────────────────────────────────────────── + + [Test] + public void StatusBarOverrides_DefaultsToAllNull () + { + var o = new StatusBarOverrides (); + Assert.That (o.Time, Is.Null); + Assert.That (o.BatteryLevel, Is.Null); + Assert.That (o.BatteryState, Is.Null); + Assert.That (o.DataNetwork, Is.Null); + Assert.That (o.CellularBars, Is.Null); + Assert.That (o.WifiBars, Is.Null); + Assert.That (o.OperatorName, Is.Null); + } + + [Test] + public void StatusBarOverrides_CanSetAllFields () + { + var o = new StatusBarOverrides ( + Time: "09:41", + BatteryLevel: 100, + BatteryState: SimulatorBatteryState.Charging, + DataNetwork: SimulatorDataNetwork.Wifi, + CellularBars: 4, + WifiBars: 3, + OperatorName: "MAUI Mobile"); + + Assert.That (o.Time, Is.EqualTo ("09:41")); + Assert.That (o.BatteryLevel, Is.EqualTo (100)); + Assert.That (o.BatteryState, Is.EqualTo (SimulatorBatteryState.Charging)); + Assert.That (o.DataNetwork, Is.EqualTo (SimulatorDataNetwork.Wifi)); + Assert.That (o.CellularBars, Is.EqualTo (4)); + Assert.That (o.WifiBars, Is.EqualTo (3)); + Assert.That (o.OperatorName, Is.EqualTo ("MAUI Mobile")); + } +}